diff --git a/.claude/commands/release-local.md b/.claude/commands/release-local.md index 8aeea134..1e8dcff9 100644 --- a/.claude/commands/release-local.md +++ b/.claude/commands/release-local.md @@ -9,19 +9,29 @@ Full end-to-end release built locally. Bumps version, updates changelog, tags, t - Get the current version from `GhosttyTabs.xcodeproj/project.pbxproj` (look for `MARKETING_VERSION`) - Bump the minor version unless the user specifies otherwise (e.g., 0.54.0 → 0.55.0) -### 2. Gather changes since the last release +### 2. Gather changes and contributors since the last release - Find the most recent git tag: `git describe --tags --abbrev=0` - Get commits since that tag: `git log --oneline ..HEAD --no-merges` - **Filter for end-user visible changes only** — ignore developer tooling, CI, docs, tests - Categorize changes into: Added, Changed, Fixed, Removed - If there are no user-facing changes, ask the user if they still want to release +- **Collect contributors:** For each PR referenced in the commits, get the author: + ```bash + gh pr view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` +- Also check for linked issue reporters (the person who filed the bug): + ```bash + gh issue view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` +- Build a deduplicated list of all contributor `@handle`s for the release ### 3. Update the changelog - Add a new section at the top of `CHANGELOG.md` with the new version and today's date - **Only include changes that affect the end-user experience** - Write clear, user-facing descriptions (not raw commit messages) +- **Credit contributors inline** (see Contributor Credits below) - Also update `docs-site/content/docs/changelog.mdx` if it exists ### 4. Bump the version @@ -72,3 +82,25 @@ If the script fails, run `say "cmux release failed"`. - Group by category: Added, Changed, Fixed, Removed - Be concise but descriptive - Focus on what the user experiences, not how it was implemented + +## Contributor Credits + +Credit the people who made each release happen. This builds community and encourages contributions. + +**Per-entry attribution** — append contributor credit after each changelog bullet: +- For code contributions (PR author): `— thanks @user!` +- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!` +- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline + +**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release: +```markdown +### Thanks to N contributors! + +- [@user1](https://github.com/user1) +- [@user2](https://github.com/user2) +``` +- List all contributors alphabetically by GitHub handle (including core team) +- Link each handle to their GitHub profile +- Include everyone: PR authors, issue reporters, anyone whose work is in the release + +**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles. diff --git a/.claude/commands/release-nightly.md b/.claude/commands/release-nightly.md index c5ce83dc..36f1a8d2 100644 --- a/.claude/commands/release-nightly.md +++ b/.claude/commands/release-nightly.md @@ -13,16 +13,26 @@ End-to-end release via PR flow: bump version, update changelog, create PR, merge 2. **Create a release branch** - Create branch: `git checkout -b release/vX.Y.Z` -3. **Gather changes since the last release** +3. **Gather changes and contributors since the last release** - Find the most recent git tag: `git describe --tags --abbrev=0` - Get commits since that tag: `git log --oneline ..HEAD --no-merges` - **Filter for end-user visible changes only** - ignore developer tooling, CI, docs, tests - Categorize changes into: Added, Changed, Fixed, Removed + - **Collect contributors:** For each PR referenced in the commits, get the author: + ```bash + gh pr view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` + - Also check for linked issue reporters (the person who filed the bug): + ```bash + gh issue view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` + - Build a deduplicated list of all contributor `@handle`s for the release 4. **Update the changelog** - Add a new section at the top of `CHANGELOG.md` with the new version and today's date - **Only include changes that affect the end-user experience** - Write clear, user-facing descriptions (not raw commit messages) + - **Credit contributors inline** (see Contributor Credits below) - Also update `docs-site/content/docs/changelog.mdx` if it exists - If there are no user-facing changes, ask the user if they still want to release @@ -73,3 +83,25 @@ If the script fails, run `say "cmux release failed"`. - Test additions or fixes - Internal refactoring with no user-visible effect - Dependency updates (unless they fix a user-facing bug) + +## Contributor Credits + +Credit the people who made each release happen. This builds community and encourages contributions. + +**Per-entry attribution** — append contributor credit after each changelog bullet: +- For code contributions (PR author): `— thanks @user!` +- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!` +- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline + +**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release: +```markdown +### Thanks to N contributors! + +- [@user1](https://github.com/user1) +- [@user2](https://github.com/user2) +``` +- List all contributors alphabetically by GitHub handle (including core team) +- Link each handle to their GitHub profile +- Include everyone: PR authors, issue reporters, anyone whose work is in the release + +**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles. diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 9ef7c00f..9903627d 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -11,16 +11,26 @@ Prepare a new release for cmux. This command updates the changelog, bumps the ve 2. **Create a release branch** - Create branch: `git checkout -b release/vX.Y.Z` -3. **Gather changes since the last release** +3. **Gather changes and contributors since the last release** - Find the most recent git tag: `git describe --tags --abbrev=0` - Get commits since that tag: `git log --oneline ..HEAD --no-merges` - **Filter for end-user visible changes only** - ignore developer tooling, CI, docs, tests - Categorize changes into: Added, Changed, Fixed, Removed + - **Collect contributors:** For each PR referenced in the commits, get the author: + ```bash + gh pr view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` + - Also check for linked issue reporters (the person who filed the bug): + ```bash + gh issue view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` + - Build a deduplicated list of all contributor `@handle`s for the release 4. **Update the changelog** - Add a new section at the top of `CHANGELOG.md` with the new version and today's date - **Only include changes that affect the end-user experience** - things users will see, feel, or interact with - Write clear, user-facing descriptions (not raw commit messages) + - **Credit contributors inline** (see Contributor Credits below) - Also update `docs-site/content/docs/changelog.mdx` with the same content - If there are no user-facing changes, ask the user if they still want to release @@ -89,18 +99,47 @@ Prepare a new release for cmux. This command updates the changelog, bumps the ve - Focus on what the user experiences, not how it was implemented - Link to issues/PRs if relevant +## Contributor Credits + +Credit the people who made each release happen. This builds community and encourages contributions. + +**Per-entry attribution** — append contributor credit after each changelog bullet: +- For code contributions (PR author): `— thanks @user!` +- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!` +- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline + +**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release: +```markdown +### Thanks to N contributors! + +- [@user1](https://github.com/user1) +- [@user2](https://github.com/user2) +``` +- List all contributors alphabetically by GitHub handle (including core team) +- Link each handle to their GitHub profile +- Include everyone: PR authors, issue reporters, anyone whose work is in the release + +**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles. + ## Example Changelog Entry ```markdown ## [0.13.0] - 2025-01-30 ### Added -- New keyboard shortcut for quick tab switching +- New keyboard shortcut for quick tab switching ([#42](https://github.com/manaflow-ai/cmux/pull/42)) — thanks @contributor! ### Fixed -- Memory leak when closing split panes -- Notification badges not clearing properly +- Memory leak when closing split panes ([#38](https://github.com/manaflow-ai/cmux/pull/38)) — thanks @fixer! +- Notification badges not clearing properly ([#35](https://github.com/manaflow-ai/cmux/pull/35)) — thanks @reporter for the report! ### Changed -- Improved terminal rendering performance +- Improved terminal rendering performance ([#40](https://github.com/manaflow-ai/cmux/pull/40)) + +### Thanks to 4 contributors! + +- [@contributor](https://github.com/contributor) +- [@fixer](https://github.com/fixer) +- [@lawrencechen](https://github.com/lawrencechen) +- [@reporter](https://github.com/reporter) ``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0b5735eb..81a47cdc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -46,6 +46,18 @@ body: validations: required: true + - type: dropdown + id: nightly-repro + attributes: + label: Can you reproduce this on cmux NIGHTLY? + description: "Please test with the latest NIGHTLY build first: https://github.com/manaflow-ai/cmux?tab=readme-ov-file#nightly-builds" + options: + - Yes, it still reproduces on NIGHTLY + - No, it does not reproduce on NIGHTLY + - I could not test NIGHTLY + validations: + required: true + - type: textarea id: description attributes: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..8e1723cd --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,33 @@ +## Summary + +- What changed? +- Why? + +## Testing + +- How did you test this change? +- What did you verify manually? + +## Demo Video + +For UI or behavior changes, include a short demo video (GitHub upload, Loom, or other direct link). + +- Video URL or attachment: + +## Review Trigger (Copy/Paste as PR comment) + +```text +@codex review +@coderabbitai review +@greptile-apps review +@cubic-dev-ai review +``` + +## Checklist + +- [ ] I tested the change locally +- [ ] I added or updated tests for behavior changes +- [ ] I updated docs/changelog if needed +- [ ] I requested bot reviews after my latest commit (copy/paste block above or equivalent) +- [ ] All code review bot comments are resolved +- [ ] All human review comments are resolved diff --git a/.github/workflows/build-ghosttykit.yml b/.github/workflows/build-ghosttykit.yml new file mode 100644 index 00000000..ec787452 --- /dev/null +++ b/.github/workflows/build-ghosttykit.yml @@ -0,0 +1,94 @@ +name: Build GhosttyKit + +on: + push: + branches: + - main + pull_request: + +jobs: + build-ghosttykit: + # Never run Depot jobs for fork pull requests (avoid billing on external PRs). + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: depot-macos-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Get ghostty SHA + id: ghostty-sha + run: | + SHA=$(git -C ghostty rev-parse HEAD) + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Ghostty SHA: $SHA" + + - name: Check if xcframework release already exists + id: check-release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="xcframework-${{ steps.ghostty-sha.outputs.sha }}" + if gh release view "$TAG" --repo manaflow-ai/ghostty >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Release $TAG already exists, skipping build" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Release $TAG not found, will build" + fi + + - name: Select Xcode + if: steps.check-release.outputs.exists == 'false' + run: | + set -euo pipefail + if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)" + if [ -n "$XCODE_APP" ]; then + XCODE_DIR="$XCODE_APP/Contents/Developer" + else + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + fi + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + + - name: Build GhosttyKit.xcframework + if: steps.check-release.outputs.exists == 'false' + run: | + set -euo pipefail + if ! command -v zig >/dev/null 2>&1; then + if command -v brew >/dev/null 2>&1; then + brew install zig + else + echo "zig is required to build GhosttyKit.xcframework. Install zig and retry." >&2 + exit 1 + fi + fi + cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast + + - name: Package xcframework + if: steps.check-release.outputs.exists == 'false' + run: | + set -euo pipefail + rm -rf GhosttyKit.xcframework + cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework + tar czf GhosttyKit.xcframework.tar.gz GhosttyKit.xcframework + + - name: Upload xcframework release + if: steps.check-release.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GHOSTTY_RELEASE_TOKEN }} + run: | + set -euo pipefail + TAG="xcframework-${{ steps.ghostty-sha.outputs.sha }}" + gh release create "$TAG" \ + --repo manaflow-ai/ghostty \ + --title "GhosttyKit xcframework (${{ steps.ghostty-sha.outputs.sha }})" \ + --notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }}" \ + GhosttyKit.xcframework.tar.gz + echo "Published release $TAG" diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml new file mode 100644 index 00000000..b6ba18dc --- /dev/null +++ b/.github/workflows/ci-macos-compat.yml @@ -0,0 +1,171 @@ +name: macOS Compatibility + +on: + push: + branches: + - main + pull_request: + +jobs: + compat-tests: + # Only run for the repo itself, not forks (GhosttyKit download needs repo access). + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-15] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Select Xcode + run: | + set -euo pipefail + # Pick the latest Xcode installed on the runner. GitHub-hosted macos-14 + # defaults to Xcode 15.4, but the project needs Xcode 16+ (Swift tools + # version 6.0 required by sentry-cocoa). + XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1)" + if [ -z "$XCODE_APP" ]; then + XCODE_APP="/Applications/Xcode.app" + fi + XCODE_DIR="$XCODE_APP/Contents/Developer" + if [ ! -d "$XCODE_DIR" ]; then + echo "No Xcode found under /Applications" >&2 + exit 1 + fi + echo "Selected: $XCODE_APP" + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + sw_vers + + - name: Download pre-built GhosttyKit.xcframework + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) + TAG="xcframework-$GHOSTTY_SHA" + URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" + echo "Downloading xcframework for ghostty $GHOSTTY_SHA" + MAX_RETRIES=30 + RETRY_DELAY=20 + for i in $(seq 1 $MAX_RETRIES); do + if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then + echo "Download succeeded on attempt $i" + break + fi + if [ "$i" -eq "$MAX_RETRIES" ]; then + echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 + exit 1 + fi + echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." + sleep $RETRY_DELAY + done + tar xzf GhosttyKit.xcframework.tar.gz + rm GhosttyKit.xcframework.tar.gz + test -d GhosttyKit.xcframework + + - name: Clean DerivedData + run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + + - name: Cache Swift packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .ci-source-packages + key: spm-${{ matrix.os }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-${{ matrix.os }}- + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Run unit tests + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + run_unit_tests() { + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" test 2>&1 + } + + set +e + OUTPUT=$(run_unit_tests) + EXIT_CODE=$? + set -e + + # SwiftPM binary artifact resolution can occasionally fail on ephemeral + # runners. Retry once after clearing caches. + if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then + echo "SwiftPM package resolution failed, clearing caches and retrying once" + rm -rf ~/Library/Caches/org.swift.swiftpm + mkdir -p ~/Library/Caches/org.swift.swiftpm + rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + set +e + OUTPUT=$(run_unit_tests) + EXIT_CODE=$? + set -e + fi + + echo "$OUTPUT" + if [ "$EXIT_CODE" -ne 0 ]; then + SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) + if echo "$SUMMARY" | grep -q "(0 unexpected)"; then + echo "All failures are expected, treating as pass" + else + echo "Unexpected test failures detected" + exit 1 + fi + fi + + - name: Create virtual display + run: | + set -euo pipefail + echo "=== Display before ===" + system_profiler SPDisplaysDataType 2>/dev/null || echo "(no display info)" + echo "" + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + /tmp/create-virtual-display & + VDISPLAY_PID=$! + echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" + sleep 3 + echo "=== Display after ===" + system_profiler SPDisplaysDataType 2>/dev/null || echo "(no display info)" + + - name: Build app for smoke test + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" build + + - name: Smoke test + run: | + set -euo pipefail + chmod +x scripts/smoke-test-ci.sh + scripts/smoke-test-ci.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e7bb8bc..22933f48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,27 @@ on: pull_request: jobs: + workflow-guard-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Validate Depot runner guards + run: ./tests/test_ci_self_hosted_guard.sh + + - name: Validate create-dmg version pinning + run: ./tests/test_ci_create_dmg_pinned.sh + + - name: Validate unit-test SwiftPM retry guard + run: ./tests/test_ci_unit_test_spm_retry.sh + + - name: Validate cmux scheme test configuration + run: ./tests/test_ci_scheme_testaction_debug.sh + + - name: Validate GhosttyKit checksum verification + run: ./tests/test_ci_ghosttykit_checksum_verification.sh + web-typecheck: runs-on: ubuntu-latest defaults: @@ -14,10 +35,10 @@ jobs: working-directory: web steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2 - name: Install dependencies run: bun install --frozen-lockfile @@ -25,14 +46,135 @@ jobs: - name: Typecheck run: bun tsc --noEmit - ui-tests: - runs-on: self-hosted - concurrency: - group: self-hosted-build - cancel-in-progress: false + tests: + runs-on: macos-15 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Select Xcode + run: | + set -euo pipefail + XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1 || true)" + if [ -z "$XCODE_APP" ]; then + XCODE_APP="/Applications/Xcode.app" + fi + XCODE_DIR="$XCODE_APP/Contents/Developer" + if [ ! -d "$XCODE_DIR" ]; then + echo "No Xcode found under /Applications" >&2 + exit 1 + fi + echo "Selected: $XCODE_APP" + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + + - name: Download pre-built GhosttyKit.xcframework + run: | + ./scripts/download-prebuilt-ghosttykit.sh + + - name: Clean DerivedData + run: | + # Remove stale build cache to avoid incremental build errors + rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + + - name: Cache Swift packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .ci-source-packages + key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm- + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Run unit tests + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + run_unit_tests() { + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" test 2>&1 + } + + # xcodebuild exits 65 even for expected failures (XCTExpectFailure). + # Capture output and fail only if there are unexpected failures. + set +e + OUTPUT=$(run_unit_tests) + EXIT_CODE=$? + set -e + + # SwiftPM binary artifact resolution can occasionally fail on ephemeral + # runners with "Could not resolve package dependencies". Retry once after + # clearing SwiftPM/DerivedData caches to recover from transient corruption. + if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then + echo "SwiftPM package resolution failed, clearing caches and retrying once" + rm -rf ~/Library/Caches/org.swift.swiftpm + mkdir -p ~/Library/Caches/org.swift.swiftpm + rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + set +e + OUTPUT=$(run_unit_tests) + EXIT_CODE=$? + set -e + fi + + echo "$OUTPUT" + if [ "$EXIT_CODE" -ne 0 ]; then + SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) + if echo "$SUMMARY" | grep -q "(0 unexpected)"; then + echo "All failures are expected, treating as pass" + else + echo "Unexpected test failures detected" + exit 1 + fi + fi + + - name: Run CLI version memory guard regression + run: | + set -euo pipefail + + CLI_BIN="$( + find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/cmux" -exec stat -f '%m %N' {} \; \ + | sort -nr \ + | head -1 \ + | cut -d' ' -f2- + )" + if [ -z "${CLI_BIN:-}" ] || [ ! -x "$CLI_BIN" ]; then + echo "cmux CLI binary not found in DerivedData" >&2 + exit 1 + fi + + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_version_memory_guard.py + + tests-depot: + # Never run Depot jobs for fork pull requests (avoid billing on external PRs). + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: depot-macos-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive @@ -42,7 +184,7 @@ jobs: if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then XCODE_DIR="/Applications/Xcode.app/Contents/Developer" else - XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)" + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" if [ -n "$XCODE_APP" ]; then XCODE_DIR="$XCODE_APP/Contents/Developer" else @@ -53,34 +195,124 @@ jobs: echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version - xcrun --sdk macosx --show-sdk-path - - name: Download Metal Toolchain - run: xcodebuild -downloadComponent MetalToolchain - - - name: Build GhosttyKit.xcframework + - name: Download pre-built GhosttyKit.xcframework run: | - set -euo pipefail - if ! command -v zig >/dev/null 2>&1; then - if command -v brew >/dev/null 2>&1; then - brew install zig - else - echo "zig is required to build GhosttyKit.xcframework. Install zig and retry." >&2 - exit 1 - fi - fi - (cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false) - rm -rf GhosttyKit.xcframework - cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework - test -d GhosttyKit.xcframework + ./scripts/download-prebuilt-ghosttykit.sh - name: Clean DerivedData + run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + + - name: Cache Swift packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .ci-source-packages + key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm- + + - name: Resolve Swift packages run: | - # Remove stale build cache to avoid incremental build errors - rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Create virtual display + run: | + set -euo pipefail + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + /tmp/create-virtual-display & + VDISPLAY_PID=$! + echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" + sleep 3 - name: Run UI tests run: | set -euo pipefail - # Run directly on the self-hosted macOS runner. - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + # SidebarResizeUITests hangs on headless runners (mouse drag simulation + # doesn't work without a physical display, even with virtual display). + # Skip it in CI; it runs fine on local machines. + run_ui_tests() { + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + -maximum-test-execution-time-allowance 120 \ + -only-testing:cmuxUITests \ + -skip-testing:cmuxUITests/SidebarResizeUITests test 2>&1 + } + + # xcodebuild exits 65 even for expected failures (XCTExpectFailure). + # Capture output and fail only if there are unexpected failures. + set +e + OUTPUT=$(run_ui_tests) + EXIT_CODE=$? + set -e + + echo "$OUTPUT" + if [ "$EXIT_CODE" -ne 0 ]; then + SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) + if echo "$SUMMARY" | grep -q "(0 unexpected)"; then + echo "All failures are expected, treating as pass" + else + echo "Unexpected test failures detected" + exit 1 + fi + fi + + - name: Run workspace churn typing-lag regression + run: | + set -euo pipefail + + APP="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/cmux DEV.app" -print -quit)" + if [ -z "${APP:-}" ] || [ ! -d "$APP" ]; then + echo "cmux DEV.app not found in DerivedData" >&2 + exit 1 + fi + + TAG="ci-lag" + SOCK="/tmp/cmux-debug-${TAG}.sock" + BUNDLE_ID="$( + /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist" 2>/dev/null \ + || echo 'com.cmuxterm.app.debug' + )" + + pkill -x "cmux DEV" || true + rm -f "$SOCK" "/tmp/cmux-${TAG}.sock" || true + defaults write "$BUNDLE_ID" socketControlMode -string full >/dev/null 2>&1 || true + + CMUX_TAG="$TAG" CMUX_SOCKET_PATH="$SOCK" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/tmp/cmux-ci-lag.log 2>&1 & + APP_PID=$! + trap 'kill "$APP_PID" >/dev/null 2>&1 || true' EXIT + + for _ in {1..240}; do + [ -S "$SOCK" ] && break + sleep 0.25 + done + [ -S "$SOCK" ] || { echo "Socket not ready at $SOCK" >&2; exit 1; } + + CMUX_SOCKET_PATH="$SOCK" \ + CMUX_LAG_MAX_P95_RATIO=1.70 \ + CMUX_LAG_MAX_AVG_RATIO=1.70 \ + CMUX_LAG_MIN_BASELINE_P95_MS_FOR_RATIO=6.0 \ + CMUX_LAG_MIN_BASELINE_AVG_MS_FOR_RATIO=4.0 \ + CMUX_LAG_MAX_P95_DELTA_MS=20.0 \ + CMUX_LAG_MAX_AVG_DELTA_MS=12.0 \ + CMUX_LAG_MAX_CHURN_P95_MS=35 \ + CMUX_LAG_KEY_EVENTS=180 \ + python3 tests/test_workspace_churn_up_arrow_lag.py diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..d300267f --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e200f251..5c46f0a3 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,9 +1,8 @@ name: Nightly macOS build on: - schedule: - # Every hour at :30. The 'decide' job skips if main has no new commits. - - cron: "30 * * * *" + push: + branches: [main] workflow_dispatch: inputs: force: @@ -12,9 +11,19 @@ on: default: false type: boolean +concurrency: + group: nightly-build-${{ github.ref_name }} + # Queue main pushes instead of hard-canceling older runs. The decide job + # already coalesces to the current main HEAD, and we re-check HEAD before + # publishing so stale queued runs exit cleanly instead of showing up red. + cancel-in-progress: false + permissions: contents: write +env: + CREATE_DMG_VERSION: 8.0.0 + jobs: decide: runs-on: ubuntu-latest @@ -22,69 +31,79 @@ jobs: should_build: ${{ steps.decide.outputs.should_build }} head_sha: ${{ steps.decide.outputs.head_sha }} short_sha: ${{ steps.decide.outputs.short_sha }} + should_publish: ${{ steps.decide.outputs.should_publish }} steps: - name: Decide whether a nightly build is needed id: decide - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }} with: script: | const forceBuild = process.env.FORCE_BUILD === 'true'; const { owner, repo } = context.repo; + const requestedRef = context.ref.startsWith('refs/heads/') + ? context.ref.replace('refs/heads/', '') + : 'main'; + const isMainRef = requestedRef === 'main'; - const branch = await github.rest.repos.getBranch({ - owner, - repo, - branch: 'main', - }); - const headSha = branch.data.commit.sha; - - let nightlySha = null; - try { - const ref = await github.rest.git.getRef({ + let headSha = context.sha; + if (isMainRef) { + const branch = await github.rest.repos.getBranch({ owner, repo, - ref: 'tags/nightly', + branch: 'main', }); - if (ref.data.object.type === 'commit') { - nightlySha = ref.data.object.sha; - } else if (ref.data.object.type === 'tag') { - const tagObject = await github.rest.git.getTag({ - owner, - repo, - tag_sha: ref.data.object.sha, - }); - nightlySha = tagObject.data.object.sha; - } - } catch (error) { - if (error.status !== 404) throw error; + headSha = branch.data.commit.sha; } - const shouldBuild = forceBuild || nightlySha !== headSha; + let nightlySha = null; + if (isMainRef) { + try { + const ref = await github.rest.git.getRef({ + owner, + repo, + ref: 'tags/nightly', + }); + if (ref.data.object.type === 'commit') { + nightlySha = ref.data.object.sha; + } else if (ref.data.object.type === 'tag') { + const tagObject = await github.rest.git.getTag({ + owner, + repo, + tag_sha: ref.data.object.sha, + }); + nightlySha = tagObject.data.object.sha; + } + } catch (error) { + if (error.status !== 404) throw error; + } + } + + const shouldBuild = !isMainRef || forceBuild || nightlySha !== headSha; core.setOutput('should_build', shouldBuild ? 'true' : 'false'); core.setOutput('head_sha', headSha); core.setOutput('short_sha', headSha.slice(0, 7)); + core.setOutput('should_publish', isMainRef ? 'true' : 'false'); core.summary .addHeading('Nightly build decision') .addTable([ - [{data: 'main HEAD', header: true}, headSha], + [{data: 'requested ref', header: true}, requestedRef], + [{data: 'build HEAD', header: true}, headSha], [{data: 'nightly tag', header: true}, nightlySha ?? '(missing)'], [{data: 'force build', header: true}, String(forceBuild)], [{data: 'should build', header: true}, String(shouldBuild)], + [{data: 'should publish', header: true}, String(isMainRef)], ]) .write(); build-sign-notarize-nightly: needs: decide if: needs.decide.outputs.should_build == 'true' - runs-on: self-hosted - concurrency: - group: self-hosted-build - cancel-in-progress: false + runs-on: macos-15 steps: - - name: Checkout main - uses: actions/checkout@v4 + - name: Checkout build ref + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.decide.outputs.head_sha }} submodules: recursive @@ -110,30 +129,18 @@ jobs: - name: Install build deps run: | - brew update - brew install zig - npm install --global create-dmg + npm install --global "create-dmg@${CREATE_DMG_VERSION}" - - name: Build GhosttyKit.xcframework + - name: Download pre-built GhosttyKit.xcframework run: | - cd ghostty - zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast - cd .. - rm -rf GhosttyKit.xcframework - cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework + ./scripts/download-prebuilt-ghosttykit.sh - - name: Clear SPM cache - run: | - rm -rf ~/Library/Caches/org.swift.swiftpm - rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* - - - name: Configure SwiftPM cache - run: | - set -euo pipefail - CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}" - rm -rf "$CACHE_DIR" - mkdir -p "$CACHE_DIR" - echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV" + - name: Cache Swift packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .spm-cache + key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm- - name: Derive Sparkle public key from private key env: @@ -147,38 +154,82 @@ jobs: echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY" echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" - - name: Build app (Release) + - name: Build Apple Silicon app (Release) run: | - xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build + xcodebuild -scheme cmux -configuration Release -derivedDataPath build-arm \ + -destination 'platform=macOS,arch=arm64' \ + -clonedSourcePackagesDirPath .spm-cache \ + ARCHS="arm64" \ + ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build - - name: Inject nightly identity and metadata + - name: Build universal app (Release) + run: | + xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \ + -destination 'generic/platform=macOS' \ + -clonedSourcePackagesDirPath .spm-cache \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build + + - name: Verify nightly binary architectures + run: | + set -euo pipefail + ARM_APP_BINARY="build-arm/Build/Products/Release/cmux.app/Contents/MacOS/cmux" + ARM_CLI_BINARY="build-arm/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" + APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux" + CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" + ARM_APP_ARCHS="$(lipo -archs "$ARM_APP_BINARY")" + ARM_CLI_ARCHS="$(lipo -archs "$ARM_CLI_BINARY")" + APP_ARCHS="$(lipo -archs "$APP_BINARY")" + CLI_ARCHS="$(lipo -archs "$CLI_BINARY")" + echo "Arm app binary architectures: $ARM_APP_ARCHS" + echo "Arm CLI binary architectures: $ARM_CLI_ARCHS" + echo "App binary architectures: $APP_ARCHS" + echo "CLI binary architectures: $CLI_ARCHS" + [[ "$ARM_APP_ARCHS" == "arm64" ]] + [[ "$ARM_CLI_ARCHS" == "arm64" ]] + [[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]] + [[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]] + + - name: Run CLI version memory guard regression + run: | + set -euo pipefail + CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" + [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; } + CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py + + - name: Check whether build commit is still current main HEAD + if: needs.decide.outputs.should_publish == 'true' + id: current_head + run: | + set -euo pipefail + CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + BUILD_SHA="${{ needs.decide.outputs.head_sha }}" + if [ "$CURRENT_MAIN_SHA" = "$BUILD_SHA" ]; then + STILL_CURRENT=true + else + STILL_CURRENT=false + fi + echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT" + { + echo "### Publish guard" + echo + echo "- build sha: \`$BUILD_SHA\`" + echo "- current main sha: \`$CURRENT_MAIN_SHA\`" + echo "- continue signing/publish: \`$STILL_CURRENT\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Inject nightly identities and metadata + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' run: | set -euo pipefail - APP_DIR="build/Build/Products/Release" - APP_PLIST="${APP_DIR}/cmux.app/Contents/Info.plist" SHORT_SHA="${{ needs.decide.outputs.short_sha }}" + ARM_APP_DIR="build-arm/Build/Products/Release" + UNIVERSAL_APP_DIR="build-universal/Build/Products/Release" - # --- Separate app identity: "cmux NIGHTLY" with its own bundle ID --- - /usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$APP_PLIST" - /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$APP_PLIST" - /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.cmuxterm.app.nightly" "$APP_PLIST" - - # Rename the .app bundle to match the display name - mv "${APP_DIR}/cmux.app" "${APP_DIR}/cmux NIGHTLY.app" - - # Update plist path after rename - APP_PLIST="${APP_DIR}/cmux NIGHTLY.app/Contents/Info.plist" - - # --- Sparkle: point at the nightly appcast --- - /usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true - /usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$APP_PLIST" >/dev/null 2>&1 || true - /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$APP_PLIST" - /usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" "$APP_PLIST" - - # Marketing version: append -nightly.YYYYMMDD so users can identify the channel and date - BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST") + BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${ARM_APP_DIR}/cmux.app/Contents/Info.plist") NIGHTLY_DATE=$(date -u +%Y%m%d) - /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$APP_PLIST" # Build number: unique/monotonic per workflow run attempt so same-day # nightlies and reruns still compare as newer in Sparkle. @@ -188,26 +239,53 @@ jobs: else NIGHTLY_BUILD="${NIGHTLY_DATE}000000" 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" + ARM_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" + UNIVERSAL_DMG_IMMUTABLE="cmux-nightly-universal-macos-${NIGHTLY_BUILD}.dmg" + echo "NIGHTLY_DMG_IMMUTABLE=${ARM_DMG_IMMUTABLE}" >> "$GITHUB_ENV" + echo "NIGHTLY_UNIVERSAL_DMG_IMMUTABLE=${UNIVERSAL_DMG_IMMUTABLE}" >> "$GITHUB_ENV" + + prepare_variant() { + local app_dir="$1" + local bundle_id="$2" + local feed_url="$3" + local app_plist="$app_dir/cmux.app/Contents/Info.plist" + + /usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$app_plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$app_plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${bundle_id}" "$app_plist" + /usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$app_plist" >/dev/null 2>&1 || true + /usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$app_plist" >/dev/null 2>&1 || true + /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$app_plist" + /usr/libexec/PlistBuddy -c "Add :SUFeedURL string ${feed_url}" "$app_plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$app_plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$app_plist" + /usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$app_plist" >/dev/null 2>&1 || true + /usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$app_plist" + mv "$app_dir/cmux.app" "$app_dir/cmux NIGHTLY.app" + } + + prepare_variant \ + "$ARM_APP_DIR" \ + "com.cmuxterm.app.nightly" \ + "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" + prepare_variant \ + "$UNIVERSAL_APP_DIR" \ + "com.cmuxterm.app.nightly.universal" \ + "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast-universal.xml" echo "Nightly app name: cmux NIGHTLY" - echo "Nightly bundle ID: com.cmuxterm.app.nightly" + echo "Nightly arm64 bundle ID: com.cmuxterm.app.nightly" + echo "Nightly universal bundle ID: com.cmuxterm.app.nightly.universal" echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" echo "Nightly build number: ${NIGHTLY_BUILD}" - echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}" + echo "Nightly arm64 immutable DMG: ${ARM_DMG_IMMUTABLE}" + echo "Nightly universal immutable DMG: ${UNIVERSAL_DMG_IMMUTABLE}" echo "Commit SHA: ${SHORT_SHA}" - name: Import signing cert + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -230,7 +308,8 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain security list-keychains -d user -s build.keychain - - name: Codesign app + - name: Codesign apps + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | @@ -238,16 +317,21 @@ jobs: echo "Missing APPLE_SIGNING_IDENTITY secret" >&2 exit 1 fi - APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app" ENTITLEMENTS="cmux.entitlements" - CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" - if [ -f "$CLI_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH" - fi - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" - /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" + for APP_PATH in \ + "build-arm/Build/Products/Release/cmux NIGHTLY.app" \ + "build-universal/Build/Products/Release/cmux NIGHTLY.app" + do + CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" + if [ -f "$CLI_PATH" ]; then + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH" + fi + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" + done - - name: Notarize app and dmg + - name: Notarize apps and dmgs + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} @@ -258,43 +342,81 @@ jobs: echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2 exit 1 fi - APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app" - ZIP_SUBMIT="cmux-nightly-notary.zip" - DMG_RELEASE="cmux-nightly-macos.dmg" - ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT" - APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" - APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")" - APP_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")" - if [ "$APP_STATUS" != "Accepted" ]; then - echo "App notarization failed with status: $APP_STATUS" >&2 - xcrun notarytool log "$APP_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true - exit 1 - fi - xcrun stapler staple "$APP_PATH" - xcrun stapler validate "$APP_PATH" - spctl -a -vv --type execute "$APP_PATH" - rm -f "$ZIP_SUBMIT" - create-dmg \ - --identity="$APPLE_SIGNING_IDENTITY" \ - "$APP_PATH" \ - ./ - mv ./"cmux NIGHTLY"*.dmg "$DMG_RELEASE" 2>/dev/null || mv ./cmux*.dmg "$DMG_RELEASE" - DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" - DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")" - DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")" - if [ "$DMG_STATUS" != "Accepted" ]; then - echo "DMG notarization failed with status: $DMG_STATUS" >&2 - xcrun notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true - exit 1 - fi - xcrun stapler staple "$DMG_RELEASE" - xcrun stapler validate "$DMG_RELEASE" + notarize_and_package() { + local app_path="$1" + local dmg_release="$2" + local dmg_immutable="$3" + local zip_submit="${dmg_release%.dmg}-notary.zip" + local dmg_tmp_dir + local created_dmg - # 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" + ditto -c -k --sequesterRsrc --keepParent "$app_path" "$zip_submit" + APP_SUBMIT_JSON="$(xcrun notarytool submit "$zip_submit" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" + APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")" + APP_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")" + if [ "$APP_STATUS" != "Accepted" ]; then + echo "App notarization failed for $app_path with status: $APP_STATUS" >&2 + xcrun notarytool log "$APP_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true + exit 1 + fi + xcrun stapler staple "$app_path" + xcrun stapler validate "$app_path" + spctl -a -vv --type execute "$app_path" + rm -f "$zip_submit" - - name: Generate Sparkle appcast (nightly) + dmg_tmp_dir="$(mktemp -d)" + create-dmg \ + --identity="$APPLE_SIGNING_IDENTITY" \ + "$app_path" \ + "$dmg_tmp_dir" + created_dmg="$(find "$dmg_tmp_dir" -maxdepth 1 -name '*.dmg' | head -n 1)" + if [ -z "$created_dmg" ]; then + echo "Failed to locate created DMG for $app_path" >&2 + exit 1 + fi + mv "$created_dmg" "$dmg_release" + rm -rf "$dmg_tmp_dir" + + DMG_SUBMIT_JSON="$(xcrun notarytool submit "$dmg_release" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" + DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")" + DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")" + if [ "$DMG_STATUS" != "Accepted" ]; then + echo "DMG notarization failed for $dmg_release with status: $DMG_STATUS" >&2 + xcrun notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true + exit 1 + fi + xcrun stapler staple "$dmg_release" + xcrun stapler validate "$dmg_release" + cp "$dmg_release" "$dmg_immutable" + } + + notarize_and_package \ + "build-arm/Build/Products/Release/cmux NIGHTLY.app" \ + "cmux-nightly-macos.dmg" \ + "$NIGHTLY_DMG_IMMUTABLE" + notarize_and_package \ + "build-universal/Build/Products/Release/cmux NIGHTLY.app" \ + "cmux-nightly-universal-macos.dmg" \ + "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" + + - name: Upload dSYMs to Sentry + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == '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-arm/Build/Products/Release/ \ + build-universal/Build/Products/Release/ + + - name: Generate Sparkle appcasts (nightly) + if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | @@ -303,8 +425,22 @@ jobs: exit 1 fi ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml + ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml + + - name: Upload branch nightly artifacts + if: needs.decide.outputs.should_publish != 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: cmux-nightly-${{ needs.decide.outputs.short_sha }} + path: | + cmux-nightly-macos*.dmg + cmux-nightly-universal-macos*.dmg + appcast.xml + appcast-universal.xml + if-no-files-found: error - name: Move nightly tag to built commit + if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true' run: | set -euo pipefail git config user.name "github-actions[bot]" @@ -313,7 +449,8 @@ jobs: git push origin refs/tags/nightly --force - name: Publish nightly release assets - uses: softprops/action-gh-release@v2 + if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true' + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: nightly name: Nightly @@ -322,13 +459,19 @@ jobs: body: | Automated nightly build for `${{ needs.decide.outputs.short_sha }}`. - **cmux NIGHTLY** is a separate app (bundle ID `com.cmuxterm.app.nightly`) that can be installed alongside the stable release. It receives nightly updates automatically via its own Sparkle feed. + **cmux NIGHTLY** has two update tracks: + - Apple Silicon: bundle ID `com.cmuxterm.app.nightly`, feed `appcast.xml` + - Universal: bundle ID `com.cmuxterm.app.nightly.universal`, feed `appcast-universal.xml` [Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + [Download cmux-nightly-universal-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-universal-macos.dmg) files: | cmux-nightly-macos-${{ github.run_id }}*.dmg cmux-nightly-macos.dmg + cmux-nightly-universal-macos-${{ github.run_id }}*.dmg + cmux-nightly-universal-macos.dmg appcast.xml + appcast-universal.xml overwrite_files: true - name: Cleanup keychain @@ -336,12 +479,3 @@ jobs: run: | security delete-keychain build.keychain >/dev/null 2>&1 || true rm -f /tmp/cert.p12 - - skipped: - needs: decide - if: needs.decide.outputs.should_build != 'true' - runs-on: ubuntu-latest - steps: - - name: No nightly build needed - run: | - echo "No changes on main since last nightly tag; skipping build." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3176697b..6a58f07f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,21 +9,21 @@ on: permissions: contents: write +env: + CREATE_DMG_VERSION: 8.0.0 + jobs: build-sign-notarize: - runs-on: self-hosted - concurrency: - group: self-hosted-build - cancel-in-progress: false + runs-on: depot-macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive - name: Guard immutable release assets id: guard_release_assets - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard'); @@ -99,37 +99,20 @@ jobs: - name: Install build deps if: steps.guard_release_assets.outputs.skip_all != 'true' run: | - brew update - brew install zig - npm install --global create-dmg + npm install --global "create-dmg@${CREATE_DMG_VERSION}" - - name: Download Metal Toolchain - if: steps.guard_release_assets.outputs.skip_all != 'true' - run: xcodebuild -downloadComponent MetalToolchain - - - name: Build GhosttyKit.xcframework + - name: Download pre-built 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 - cd .. - rm -rf GhosttyKit.xcframework - cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework + ./scripts/download-prebuilt-ghosttykit.sh - - name: Clear SPM cache + - name: Cache Swift packages 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}" - rm -rf "$CACHE_DIR" - mkdir -p "$CACHE_DIR" - echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV" + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .spm-cache + key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm- - name: Derive Sparkle public key from private key if: steps.guard_release_assets.outputs.skip_all != 'true' @@ -147,7 +130,17 @@ jobs: - name: Build app (Release) if: steps.guard_release_assets.outputs.skip_all != 'true' run: | - xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build + xcodebuild -scheme cmux -configuration Release -derivedDataPath build \ + -clonedSourcePackagesDirPath .spm-cache \ + CODE_SIGNING_ALLOWED=NO build + + - name: Run CLI version memory guard regression + if: steps.guard_release_assets.outputs.skip_all != 'true' + run: | + set -euo pipefail + CLI_BINARY="build/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" + [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; } + CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py - name: Inject Sparkle keys into Info.plist if: steps.guard_release_assets.outputs.skip_all != 'true' @@ -250,6 +243,20 @@ 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: @@ -263,7 +270,7 @@ jobs: - name: Upload release asset if: steps.guard_release_assets.outputs.skip_upload != 'true' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: files: | cmux-macos.dmg diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml new file mode 100644 index 00000000..ca636bf6 --- /dev/null +++ b/.github/workflows/test-depot.yml @@ -0,0 +1,187 @@ +name: Run tests on Depot + +on: + workflow_dispatch: + inputs: + ref: + description: Branch or SHA to test + required: false + default: "" + skip_unit_tests: + description: Skip unit tests (run only UI tests) + required: false + default: false + type: boolean + skip_ui_tests: + description: Skip UI tests (run only unit tests) + required: false + default: false + type: boolean + test_filter: + description: "Run specific UI test class (e.g. UpdatePillUITests) or empty for all" + required: false + default: "" + test_timeout: + description: "Per-test timeout in seconds (default: 120)" + required: false + default: "120" + +jobs: + tests: + runs-on: depot-macos-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + submodules: recursive + + - name: Select Xcode + run: | + set -euo pipefail + if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)" + if [ -n "$XCODE_APP" ]; then + XCODE_DIR="$XCODE_APP/Contents/Developer" + else + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + fi + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + + - name: Download pre-built GhosttyKit.xcframework + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) + TAG="xcframework-$GHOSTTY_SHA" + URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" + echo "Downloading xcframework for ghostty $GHOSTTY_SHA" + MAX_RETRIES=30 + RETRY_DELAY=20 + for i in $(seq 1 $MAX_RETRIES); do + if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then + echo "Download succeeded on attempt $i" + break + fi + if [ "$i" -eq "$MAX_RETRIES" ]; then + echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 + exit 1 + fi + echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." + sleep $RETRY_DELAY + done + tar xzf GhosttyKit.xcframework.tar.gz + rm GhosttyKit.xcframework.tar.gz + test -d GhosttyKit.xcframework + + - name: Create virtual display + run: | + set -euo pipefail + echo "=== Display before ===" + system_profiler SPDisplaysDataType 2>/dev/null || echo "(none)" + echo "" + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + /tmp/create-virtual-display & + VDISPLAY_PID=$! + echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" + sleep 3 + echo "=== Display after ===" + system_profiler SPDisplaysDataType 2>/dev/null || echo "(none)" + + - name: Clean DerivedData + run: | + rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + rm -rf "$SOURCE_PACKAGES_DIR" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Run unit tests + if: ${{ !inputs.skip_unit_tests }} + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + run_unit_tests() { + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" test 2>&1 + } + + set +e + OUTPUT=$(run_unit_tests) + EXIT_CODE=$? + set -e + + # SwiftPM binary artifact resolution can occasionally fail with + # "Could not resolve package dependencies". Retry once after clearing + # SwiftPM/DerivedData caches to recover from transient corruption. + if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then + echo "SwiftPM package resolution failed, clearing caches and retrying once" + rm -rf ~/Library/Caches/org.swift.swiftpm + rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + set +e + OUTPUT=$(run_unit_tests) + EXIT_CODE=$? + set -e + fi + + echo "$OUTPUT" + if [ "$EXIT_CODE" -ne 0 ]; then + SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) + if echo "$SUMMARY" | grep -q "(0 unexpected)"; then + echo "All failures are expected, treating as pass" + else + echo "Unexpected test failures detected" + exit 1 + fi + fi + + - name: Run UI tests + if: ${{ !inputs.skip_ui_tests }} + env: + TEST_FILTER: ${{ inputs.test_filter }} + TEST_TIMEOUT: ${{ inputs.test_timeout || '120' }} + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + + # Build the -only-testing argument + if [ -n "$TEST_FILTER" ]; then + ONLY_TESTING="-only-testing:cmuxUITests/$TEST_FILTER" + else + ONLY_TESTING="-only-testing:cmuxUITests" + fi + + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + -maximum-test-execution-time-allowance "$TEST_TIMEOUT" \ + $ONLY_TESTING test diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml new file mode 100644 index 00000000..23d595c7 --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,363 @@ +name: E2E test with video recording + +on: + workflow_dispatch: + inputs: + ref: + description: Branch or SHA to test + required: false + default: "" + test_filter: + description: "Test class or class/method (e.g. UpdatePillUITests or UpdatePillUITests/testSomething)" + required: true + test_timeout: + description: "Per-test timeout in seconds" + required: false + default: "120" + record_video: + description: Record the virtual display during tests + required: false + default: true + type: boolean + runner: + description: "Runner OS (macos-15 or macos-26)" + required: false + default: "macos-15" + type: choice + options: + - macos-15 + - macos-26 + +jobs: + e2e: + runs-on: ${{ inputs.runner || 'macos-15' }} + env: + TEST_REF: ${{ inputs.ref || github.ref }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ inputs.ref || github.ref }} + submodules: recursive + + - name: Capture SHA + id: sha + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Select Xcode + run: | + set -euo pipefail + if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)" + if [ -n "$XCODE_APP" ]; then + XCODE_DIR="$XCODE_APP/Contents/Developer" + else + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + fi + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + + - name: Download pre-built GhosttyKit.xcframework + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) + TAG="xcframework-$GHOSTTY_SHA" + URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" + echo "Downloading xcframework for ghostty $GHOSTTY_SHA" + MAX_RETRIES=30 + RETRY_DELAY=20 + for i in $(seq 1 $MAX_RETRIES); do + if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then + echo "Download succeeded on attempt $i" + break + fi + if [ "$i" -eq "$MAX_RETRIES" ]; then + echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 + exit 1 + fi + echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." + sleep $RETRY_DELAY + done + tar xzf GhosttyKit.xcframework.tar.gz + rm GhosttyKit.xcframework.tar.gz + test -d GhosttyKit.xcframework + + - name: Create virtual display + run: | + set -euo pipefail + echo "=== Display before ===" + system_profiler SPDisplaysDataType 2>/dev/null || echo "(none)" + echo "" + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + /tmp/create-virtual-display & + VDISPLAY_PID=$! + echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" + sleep 3 + echo "=== Display after ===" + system_profiler SPDisplaysDataType 2>/dev/null || echo "(none)" + + - name: Install ffmpeg + if: ${{ inputs.record_video }} + run: | + brew install --quiet ffmpeg + FFMPEG_PATH=$(which ffmpeg) + echo "ffmpeg: $FFMPEG_PATH" + ffmpeg -version | head -1 + echo "FFMPEG_PATH=$FFMPEG_PATH" >> "$GITHUB_ENV" + + - name: Grant TCC screen recording permission + if: ${{ inputs.record_video }} + continue-on-error: true + run: | + FFMPEG_BIN="${FFMPEG_PATH:-/opt/homebrew/bin/ffmpeg}" + + # System-level TCC database (where kTCCServiceScreenCapture lives) + SYS_TCC="/Library/Application Support/com.apple.TCC/TCC.db" + if [ -f "$SYS_TCC" ]; then + echo "Granting screen capture in system TCC database" + for client in "$FFMPEG_BIN" /opt/homebrew/bin/ffmpeg /usr/local/bin/ffmpeg /usr/sbin/screencapture; do + sudo sqlite3 "$SYS_TCC" \ + "INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version, csreq, policy_id, indirect_object_identifier_type, indirect_object_identifier, indirect_object_code_identity, flags, last_modified) VALUES ('kTCCServiceScreenCapture', '$client', 1, 2, 4, 1, NULL, NULL, 0, 'UNUSED', NULL, 0, $(date +%s));" 2>&1 || echo " (failed for $client)" + done + fi + + # User-level TCC database (fallback) + USER_TCC="$HOME/Library/Application Support/com.apple.TCC/TCC.db" + if [ -f "$USER_TCC" ]; then + echo "Granting screen capture in user TCC database" + for client in "$FFMPEG_BIN" /opt/homebrew/bin/ffmpeg /usr/local/bin/ffmpeg; do + sqlite3 "$USER_TCC" \ + "INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version, csreq, policy_id, indirect_object_identifier_type, indirect_object_identifier, indirect_object_code_identity, flags, last_modified) VALUES ('kTCCServiceScreenCapture', '$client', 1, 2, 4, 1, NULL, NULL, 0, 'UNUSED', NULL, 0, $(date +%s));" 2>&1 || echo " (failed for $client)" + done + fi + + # Suppress Sequoia's ScreenCaptureApprovals prompt by pre-dating approval + APPROVALS_PLIST="$HOME/Library/Group Containers/group.com.apple.replayd/ScreenCaptureApprovals.plist" + if [ -d "$(dirname "$APPROVALS_PLIST")" ]; then + echo "Pre-dating ScreenCaptureApprovals" + # Set approval date far in the future so the monthly prompt never fires + defaults write "$APPROVALS_PLIST" "$FFMPEG_BIN" -date "3000-01-01T00:00:00Z" 2>&1 || echo " (failed)" + fi + + - name: Clean DerivedData + run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + + - name: Cache Swift packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .ci-source-packages + key: spm-${{ inputs.runner || 'macos-15' }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-${{ inputs.runner || 'macos-15' }}- + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Run UI tests + id: tests + env: + TEST_FILTER: ${{ inputs.test_filter }} + TEST_TIMEOUT: ${{ inputs.test_timeout || '120' }} + RECORD_VIDEO: ${{ inputs.record_video }} + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + ONLY_TESTING="-only-testing:cmuxUITests/$TEST_FILTER" + + # Start recording right before the test (after build/resolve) + if [ "$RECORD_VIDEO" = "true" ]; then + DEVLIST=$( ffmpeg -f avfoundation -list_devices true -i "" 2>&1 || true ) + echo "Available devices:" + echo "$DEVLIST" | grep -E "AVFoundation|Capture screen" + + SCREEN_INDEX=$( echo "$DEVLIST" | grep "Capture screen" | head -1 \ + | sed 's/.*\[\([0-9]*\)\].*/\1/' ) + SCREEN_INDEX="${SCREEN_INDEX:-0}" + echo "Using screen device index: $SCREEN_INDEX" + + ffmpeg -f avfoundation -framerate 10 -capture_cursor 1 \ + -i "${SCREEN_INDEX}:none" \ + -c:v libx264 -preset ultrafast -pix_fmt yuv420p \ + /tmp/test-recording-raw.mp4 /tmp/ffmpeg.log 2>&1 & + RECORD_PID=$! + echo "RECORD_PID=$RECORD_PID" >> "$GITHUB_ENV" + sleep 2 + + if kill -0 "$RECORD_PID" 2>/dev/null; then + echo "Recording started (PID $RECORD_PID)" + else + echo "::warning::ffmpeg screen recording failed to start" + cat /tmp/ffmpeg.log + fi + fi + + set +e + OUTPUT=$(xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + -maximum-test-execution-time-allowance "$TEST_TIMEOUT" \ + $ONLY_TESTING test 2>&1) + EXIT_CODE=$? + set -e + + echo "$OUTPUT" + + # Save summary for the issue + SUMMARY=$(echo "$OUTPUT" | grep -E "(Test Suite|Executed|FAIL|PASS)" | tail -20) + { + echo "test_summary<> "$GITHUB_OUTPUT" + + if [ "$EXIT_CODE" -eq 0 ]; then + echo "test_result=passed" >> "$GITHUB_OUTPUT" + else + echo "test_result=failed" >> "$GITHUB_OUTPUT" + # Save full output for the issue body + { + echo "test_output<> "$GITHUB_OUTPUT" + exit 1 + fi + + - name: Stop recording and trim + if: ${{ always() && inputs.record_video && env.RECORD_PID != '' }} + run: | + # Stop ffmpeg cleanly + kill -INT "$RECORD_PID" 2>/dev/null || true + for i in $(seq 1 15); do + if ! kill -0 "$RECORD_PID" 2>/dev/null; then + echo "Recording stopped after ${i}s" + break + fi + sleep 1 + done + kill -9 "$RECORD_PID" 2>/dev/null || true + + echo "=== raw recording ===" + ls -lh /tmp/test-recording-raw.mp4 2>/dev/null || { echo "No recording file"; exit 0; } + + # Trim: detect first non-black frame and cut from there + BLACK_END=$(ffmpeg -i /tmp/test-recording-raw.mp4 \ + -vf "blackdetect=d=0.3:pic_th=0.95:pix_th=0.1" \ + -an -f null - 2>&1 \ + | grep "black_end" | tail -1 \ + | sed 's/.*black_end:\([0-9.]*\).*/\1/' || true) + + if [ -n "$BLACK_END" ] && [ "$BLACK_END" != "0" ]; then + echo "Trimming ${BLACK_END}s of black frames from start" + ffmpeg -y -i /tmp/test-recording-raw.mp4 -ss "$BLACK_END" \ + -c:v libx264 -preset ultrafast -pix_fmt yuv420p \ + /tmp/test-recording.mp4 2>/dev/null + else + echo "No black frames detected, using raw recording" + mv /tmp/test-recording-raw.mp4 /tmp/test-recording.mp4 + fi + + echo "=== final recording ===" + ls -lh /tmp/test-recording.mp4 + # Print duration + ffprobe -v error -show_entries format=duration \ + -of default=noprint_wrappers=1:nokey=1 /tmp/test-recording.mp4 2>/dev/null \ + | xargs -I{} echo "Duration: {}s" + + - name: Upload recording artifact + if: ${{ always() && inputs.record_video }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: test-recording + path: /tmp/test-recording.mp4 + if-no-files-found: warn + + - name: Post results to cmux-dev-artifacts + if: always() + env: + GH_TOKEN: ${{ secrets.DEV_ARTIFACTS_TOKEN }} + TEST_RESULT: ${{ steps.tests.outputs.test_result || 'failed' }} + TEST_SUMMARY: ${{ steps.tests.outputs.test_summary }} + TEST_OUTPUT: ${{ steps.tests.outputs.test_output }} + TEST_FILTER: ${{ inputs.test_filter }} + COMMIT_SHA: ${{ steps.sha.outputs.sha }} + RUN_ID: ${{ github.run_id }} + RECORD_VIDEO: ${{ inputs.record_video }} + run: | + set -euo pipefail + + LABEL="$TEST_RESULT" + if [ "$TEST_RESULT" = "passed" ]; then + STATUS_EMOJI="PASSED" + else + STATUS_EMOJI="FAILED" + fi + + REF_DISPLAY="${{ inputs.ref || github.ref_name }}" + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/$RUN_ID" + ARTIFACT_URL="$RUN_URL#artifacts" + + BODY="**Status:** $STATUS_EMOJI + **Ref:** \`$REF_DISPLAY\` + **SHA:** [\`${COMMIT_SHA:0:12}\`](https://github.com/${{ github.repository }}/commit/$COMMIT_SHA) + **Test:** \`$TEST_FILTER\` + **Workflow run:** $RUN_URL" + + if [ "$RECORD_VIDEO" = "true" ]; then + BODY="$BODY + **Recording:** [Download from artifacts]($ARTIFACT_URL)" + fi + + if [ -n "$TEST_OUTPUT" ]; then + BODY="$BODY + +
Test output (last 200 lines) + + \`\`\` + $TEST_OUTPUT + \`\`\` + +
" + fi + + if [ -n "$TEST_SUMMARY" ]; then + BODY="$BODY + + \`\`\` + $TEST_SUMMARY + \`\`\`" + fi + + ISSUE_URL=$(gh issue create \ + --repo manaflow-ai/cmux-dev-artifacts \ + --title "[$STATUS_EMOJI] $TEST_FILTER @ ${COMMIT_SHA:0:7} ($REF_DISPLAY)" \ + --body "$BODY" \ + --label "$LABEL") + + echo "Issue posted: $ISSUE_URL" + echo "::notice title=Test Result Issue::$ISSUE_URL" diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml index 17c07fb5..b8c4d705 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -37,11 +37,22 @@ jobs: echo "Could not determine version" >&2 exit 1 fi + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "Invalid version: ${VERSION}" >&2 + exit 1 + fi + echo "Skipping homebrew cask update for non-release ref: ${VERSION}" + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + fi + echo "skip=false" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Updating homebrew cask to version $VERSION" - name: Download DMG and get SHA256 id: sha + if: steps.version.outputs.skip != 'true' run: | VERSION="${{ steps.version.outputs.version }}" URL="https://github.com/manaflow-ai/cmux/releases/download/v${VERSION}/cmux-macos.dmg" @@ -65,13 +76,15 @@ jobs: echo "DMG SHA256: $SHA256" - name: Checkout homebrew-cmux - uses: actions/checkout@v4 + if: steps.version.outputs.skip != 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: manaflow-ai/homebrew-cmux token: ${{ secrets.HOMEBREW_TAP_TOKEN }} path: homebrew-cmux - name: Update cask formula + if: steps.version.outputs.skip != 'true' env: VERSION: ${{ steps.version.outputs.version }} SHA256: ${{ steps.sha.outputs.sha256 }} @@ -94,6 +107,7 @@ jobs: depends_on macos: ">= :sonoma" app "cmux.app" + binary "#{appdir}/cmux.app/Contents/Resources/bin/cmux" zap trash: [ "~/Library/Application Support/cmux", @@ -106,6 +120,7 @@ jobs: sed -i 's/^ //' homebrew-cmux/Casks/cmux.rb - name: Verify cask SHA matches DMG + if: steps.version.outputs.skip != 'true' run: | CASK_SHA=$(grep 'sha256' homebrew-cmux/Casks/cmux.rb | sed 's/.*"\(.*\)".*/\1/') ACTUAL_SHA=$(shasum -a 256 cmux.dmg | cut -d' ' -f1) @@ -116,6 +131,7 @@ jobs: echo "SHA verification passed: $CASK_SHA" - name: Commit and push + if: steps.version.outputs.skip != 'true' env: VERSION: ${{ steps.version.outputs.version }} run: | diff --git a/AppIcon.icon/Assets/cmux-icon-chevron 2.png b/AppIcon.icon/Assets/cmux-icon-chevron 2.png new file mode 100644 index 00000000..9e5f23f1 Binary files /dev/null and b/AppIcon.icon/Assets/cmux-icon-chevron 2.png differ diff --git a/AppIcon.icon/icon.json b/AppIcon.icon/icon.json new file mode 100644 index 00000000..e4ddba51 --- /dev/null +++ b/AppIcon.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill" : "automatic", + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name" : "cmux-icon-chevron 2.png", + "name" : "cmux-icon-chevron 2", + "position" : { + "scale" : 1, + "translation-in-points" : [ + 37.357790031201375, + -0.5 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/128.png b/Assets.xcassets/AppIcon-Debug.appiconset/128.png index 38a667a1..f3915340 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/128.png and b/Assets.xcassets/AppIcon-Debug.appiconset/128.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png index d58bd7ed..7e65f28a 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/16.png b/Assets.xcassets/AppIcon-Debug.appiconset/16.png index cff0d96c..2db4b3ad 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/16.png and b/Assets.xcassets/AppIcon-Debug.appiconset/16.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png index 0514b3ce..03df358a 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/256.png b/Assets.xcassets/AppIcon-Debug.appiconset/256.png index d58bd7ed..7e65f28a 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/256.png and b/Assets.xcassets/AppIcon-Debug.appiconset/256.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png index 8b5bb49e..aab61e88 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/32.png b/Assets.xcassets/AppIcon-Debug.appiconset/32.png index 0514b3ce..03df358a 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/32.png and b/Assets.xcassets/AppIcon-Debug.appiconset/32.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png index dfeae3ae..8e2f7fa6 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/512.png b/Assets.xcassets/AppIcon-Debug.appiconset/512.png index 8b5bb49e..aab61e88 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/512.png and b/Assets.xcassets/AppIcon-Debug.appiconset/512.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png index 2188fe54..8d15af57 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/128.png b/Assets.xcassets/AppIcon.appiconset/128.png index b458571a..713a81f1 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/128.png and b/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/128@2x.png b/Assets.xcassets/AppIcon.appiconset/128@2x.png index 158d4b64..7028d73c 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/128@2x.png and b/Assets.xcassets/AppIcon.appiconset/128@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png new file mode 100644 index 00000000..2fd855c2 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/128_dark.png b/Assets.xcassets/AppIcon.appiconset/128_dark.png new file mode 100644 index 00000000..126aae76 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/128_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16.png b/Assets.xcassets/AppIcon.appiconset/16.png index 43570df5..f7fc3199 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/16.png and b/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16@2x.png b/Assets.xcassets/AppIcon.appiconset/16@2x.png index 1e3fd85b..ae5aa984 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/16@2x.png and b/Assets.xcassets/AppIcon.appiconset/16@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png new file mode 100644 index 00000000..b682b7d5 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16_dark.png b/Assets.xcassets/AppIcon.appiconset/16_dark.png new file mode 100644 index 00000000..d861db54 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/16_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256.png b/Assets.xcassets/AppIcon.appiconset/256.png index 37255441..7028d73c 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/256.png and b/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256@2x.png b/Assets.xcassets/AppIcon.appiconset/256@2x.png index 52e0e222..b3393bcd 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/256@2x.png and b/Assets.xcassets/AppIcon.appiconset/256@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png new file mode 100644 index 00000000..9de53249 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256_dark.png b/Assets.xcassets/AppIcon.appiconset/256_dark.png new file mode 100644 index 00000000..2fd855c2 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/256_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32.png b/Assets.xcassets/AppIcon.appiconset/32.png index 1e3fd85b..ae5aa984 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/32.png and b/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32@2x.png b/Assets.xcassets/AppIcon.appiconset/32@2x.png index c97a8c72..e9ec63c6 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/32@2x.png and b/Assets.xcassets/AppIcon.appiconset/32@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png new file mode 100644 index 00000000..df4110fa Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32_dark.png b/Assets.xcassets/AppIcon.appiconset/32_dark.png new file mode 100644 index 00000000..b682b7d5 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/32_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512.png b/Assets.xcassets/AppIcon.appiconset/512.png index 52e0e222..b3393bcd 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/512.png and b/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512@2x.png b/Assets.xcassets/AppIcon.appiconset/512@2x.png index 5a099e36..847feeb5 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/512@2x.png and b/Assets.xcassets/AppIcon.appiconset/512@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png new file mode 100644 index 00000000..83b79438 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512_dark.png b/Assets.xcassets/AppIcon.appiconset/512_dark.png new file mode 100644 index 00000000..9de53249 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/512_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json index 93a6772e..b63ce430 100644 --- a/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,188 @@ { - "images" : [ + "images": [ { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" + "filename": "16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" }, { - "filename" : "16@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "16_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" }, { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" + "filename": "16@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" }, { - "filename" : "32@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "16@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" }, { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" + "filename": "32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" }, { - "filename" : "128@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "32_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" }, { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" + "filename": "32@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" }, { - "filename" : "256@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "32@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" }, { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" + "filename": "128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" }, { - "filename" : "512@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "128_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "128@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "128@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "256_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "256@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "256@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "512.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "512_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "512@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "512@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/Assets.xcassets/AppIconDark.imageset/AppIconDark.png b/Assets.xcassets/AppIconDark.imageset/AppIconDark.png new file mode 100644 index 00000000..83b79438 Binary files /dev/null and b/Assets.xcassets/AppIconDark.imageset/AppIconDark.png differ diff --git a/Assets.xcassets/AppIconDark.imageset/Contents.json b/Assets.xcassets/AppIconDark.imageset/Contents.json new file mode 100644 index 00000000..ef554911 --- /dev/null +++ b/Assets.xcassets/AppIconDark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AppIconDark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/AppIconLight.imageset/AppIconLight.png b/Assets.xcassets/AppIconLight.imageset/AppIconLight.png new file mode 100644 index 00000000..847feeb5 Binary files /dev/null and b/Assets.xcassets/AppIconLight.imageset/AppIconLight.png differ diff --git a/Assets.xcassets/AppIconLight.imageset/Contents.json b/Assets.xcassets/AppIconLight.imageset/Contents.json new file mode 100644 index 00000000..c2e50ab0 --- /dev/null +++ b/Assets.xcassets/AppIconLight.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AppIconLight.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index dc79f8fb..ffa49338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,191 @@ All notable changes to cmux are documented here. +## [0.62.1] - 2026-03-13 + +### Added +- Cmd+T (New tab) shortcut on the welcome screen ([#1258](https://github.com/manaflow-ai/cmux/pull/1258)) + +### Fixed +- Cmd+backtick window cycling skipping windows +- Titlebar shortcut hint clipping ([#1259](https://github.com/manaflow-ai/cmux/pull/1259)) +- Terminal portals desyncing after sidebar changes ([#1253](https://github.com/manaflow-ai/cmux/pull/1253)) +- Background terminal focus retries reordering windows +- Pure-style multiline prompt redraws in Ghostty +- Return key not working on Cmd+Ctrl+W close confirmation ([#1279](https://github.com/manaflow-ai/cmux/pull/1279)) +- Concurrent remote daemon RPC calls timing out ([#1281](https://github.com/manaflow-ai/cmux/pull/1281)) + +### Removed +- SSH remote port proxying (reverted, will return in a future release) + +## [0.62.0] - 2026-03-12 + +### Added +- Markdown viewer panel with live file watching ([#883](https://github.com/manaflow-ai/cmux/pull/883)) +- Find-in-page (Cmd+F) for browser panels ([#837](https://github.com/manaflow-ai/cmux/issues/837), [#875](https://github.com/manaflow-ai/cmux/pull/875)) +- Keyboard copy mode for terminal scrollback with vi-style navigation ([#792](https://github.com/manaflow-ai/cmux/pull/792)) +- Custom notification sounds with file picker support ([#839](https://github.com/manaflow-ai/cmux/pull/839), [#869](https://github.com/manaflow-ai/cmux/pull/869)) +- Browser camera and microphone permission support ([#760](https://github.com/manaflow-ai/cmux/issues/760), [#913](https://github.com/manaflow-ai/cmux/pull/913)) +- Language setting for per-app locale override ([#886](https://github.com/manaflow-ai/cmux/pull/886)) +- Japanese localization ([#819](https://github.com/manaflow-ai/cmux/pull/819)) +- 16 new languages added to localization ([#895](https://github.com/manaflow-ai/cmux/pull/895)) +- Kagi as a search provider option ([#561](https://github.com/manaflow-ai/cmux/pull/561)) +- Open Folder command (Cmd+O) ([#656](https://github.com/manaflow-ai/cmux/pull/656)) +- Dark mode app icon for macOS Sequoia ([#702](https://github.com/manaflow-ai/cmux/pull/702)) +- Close other pane tabs with confirmation ([#475](https://github.com/manaflow-ai/cmux/pull/475)) +- Flash Focused Panel command palette action ([#638](https://github.com/manaflow-ai/cmux/pull/638)) +- Zoom/maximize focused pane in splits ([#634](https://github.com/manaflow-ai/cmux/pull/634)) +- `cmux tree` command for full CLI hierarchy view ([#592](https://github.com/manaflow-ai/cmux/pull/592)) +- Install or uninstall the `cmux` CLI from the command palette ([#626](https://github.com/manaflow-ai/cmux/pull/626)) +- Clipboard image paste in terminal with Cmd+V ([#562](https://github.com/manaflow-ai/cmux/pull/562), [#853](https://github.com/manaflow-ai/cmux/pull/853)) +- Middle-click X11-style selection paste in terminal ([#369](https://github.com/manaflow-ai/cmux/pull/369)) +- Honor Ghostty `background-opacity` across all cmux chrome ([#667](https://github.com/manaflow-ai/cmux/pull/667)) +- Setting to hide Cmd-hold shortcut hints ([#765](https://github.com/manaflow-ai/cmux/pull/765)) +- Focus-follows-mouse on terminal hover ([#519](https://github.com/manaflow-ai/cmux/pull/519)) +- Sidebar help menu in the footer ([#958](https://github.com/manaflow-ai/cmux/pull/958)) +- External URL bypass rules for the embedded browser ([#768](https://github.com/manaflow-ai/cmux/pull/768)) +- Telemetry opt-out setting ([#610](https://github.com/manaflow-ai/cmux/pull/610)) +- Browser automation docs page ([#622](https://github.com/manaflow-ai/cmux/pull/622)) +- Vim mode indicator badge on terminal panes ([#1092](https://github.com/manaflow-ai/cmux/pull/1092)) +- Sidebar workspace color in CLI sidebar_state output ([#1101](https://github.com/manaflow-ai/cmux/pull/1101)) +- Prompt before closing window with Cmd+Ctrl+W ([#1219](https://github.com/manaflow-ai/cmux/pull/1219)) +- Jump to Latest button in notifications popover ([#1167](https://github.com/manaflow-ai/cmux/pull/1167)) +- Khmer localization ([#1198](https://github.com/manaflow-ai/cmux/pull/1198)) +- cmux claude-teams launcher ([#1179](https://github.com/manaflow-ai/cmux/pull/1179)) + +### Changed +- Command palette search is now async and decoupled from typing for reduced lag +- Fuzzy matching improved with single-edit and omitted-character word matches +- Replaced keychain password storage with file-based storage ([#576](https://github.com/manaflow-ai/cmux/pull/576)) +- Fullscreen shortcut changed to Cmd+Ctrl+F, and Cmd+Enter also toggles fullscreen ([#530](https://github.com/manaflow-ai/cmux/pull/530)) +- Workspace rename shortcut Cmd+Shift+R now uses the command palette flow +- Renamed tab color to workspace color in user-facing strings ([#637](https://github.com/manaflow-ai/cmux/pull/637)) +- Feedback recipient changed to `feedback@manaflow.com` ([#1007](https://github.com/manaflow-ai/cmux/pull/1007)) +- Regenerated app icons from Icon Composer ([#1005](https://github.com/manaflow-ai/cmux/pull/1005)) +- Moved update logs into the Debug menu ([#1008](https://github.com/manaflow-ai/cmux/pull/1008)) +- Updated Ghostty to v1.3.0 ([#1142](https://github.com/manaflow-ai/cmux/pull/1142)) +- Welcome screen colors adapted for light mode ([#1214](https://github.com/manaflow-ai/cmux/pull/1214)) +- Notification sound picker width constrained ([#1168](https://github.com/manaflow-ai/cmux/pull/1168)) + +### Fixed +- Frozen blank launch from session restore race condition ([#399](https://github.com/manaflow-ai/cmux/issues/399), [#565](https://github.com/manaflow-ai/cmux/pull/565)) +- Crash on launch from an exclusive access violation in drag-handle hit testing ([#490](https://github.com/manaflow-ai/cmux/issues/490)) +- Use-after-free in `ghostty_surface_refresh` after sleep/wake ([#432](https://github.com/manaflow-ai/cmux/issues/432), [#619](https://github.com/manaflow-ai/cmux/pull/619)) +- Startup SIGSEGV by pre-warming locale before `SentrySDK.start` ([#927](https://github.com/manaflow-ai/cmux/pull/927)) +- IME issues: Shift+Space toggle inserting a space ([#641](https://github.com/manaflow-ai/cmux/issues/641), [#670](https://github.com/manaflow-ai/cmux/pull/670)), Ctrl fast path blocking IME events, browser address bar Japanese IME ([#789](https://github.com/manaflow-ai/cmux/issues/789), [#867](https://github.com/manaflow-ai/cmux/pull/867)), and Cmd shortcuts during IME composition +- CLI socket autodiscovery for tagged sockets ([#832](https://github.com/manaflow-ai/cmux/pull/832)) +- Flaky CLI socket listener recovery ([#952](https://github.com/manaflow-ai/cmux/issues/952), [#954](https://github.com/manaflow-ai/cmux/pull/954)) +- Side-docked dev tools resize ([#712](https://github.com/manaflow-ai/cmux/pull/712)) +- Dvorak Cmd+C colliding with the notifications shortcut ([#762](https://github.com/manaflow-ai/cmux/pull/762)) +- Terminal drag hover overlay flicker +- Titlebar controls clipped at the bottom edge ([#1016](https://github.com/manaflow-ai/cmux/pull/1016)) +- Sidebar git branch recovery after sleep/wake and agent checkout ([#494](https://github.com/manaflow-ai/cmux/issues/494), [#671](https://github.com/manaflow-ai/cmux/pull/671), [#905](https://github.com/manaflow-ai/cmux/pull/905)) +- Browser portal routing, uploads, and click focus regressions ([#908](https://github.com/manaflow-ai/cmux/pull/908), [#961](https://github.com/manaflow-ai/cmux/pull/961)) +- Notification unread persistence on workspace focus +- Escape propagation when the command palette is visible ([#847](https://github.com/manaflow-ai/cmux/pull/847)) +- Cmd+Shift+Enter pane zoom regression in browser focus ([#826](https://github.com/manaflow-ai/cmux/pull/826)) +- Cross-window theme background after jump-to-unread ([#861](https://github.com/manaflow-ai/cmux/pull/861)) +- `window.open()` and `target=_blank` not opening in a new tab ([#693](https://github.com/manaflow-ai/cmux/pull/693)) +- Terminal wrap width for the overlay scrollbar ([#522](https://github.com/manaflow-ai/cmux/pull/522)) +- Orphaned child processes when closing workspace tabs ([#889](https://github.com/manaflow-ai/cmux/pull/889)) +- Cmd+F Escape passthrough into terminal ([#918](https://github.com/manaflow-ai/cmux/pull/918)) +- Terminal link opens staying in the source workspace ([#912](https://github.com/manaflow-ai/cmux/pull/912)) +- Ghost terminal surface rebind after close ([#808](https://github.com/manaflow-ai/cmux/pull/808)) +- Cmd+plus zoom handling on non-US keyboard layouts ([#680](https://github.com/manaflow-ai/cmux/pull/680)) +- Menubar icon invisible in light mode ([#741](https://github.com/manaflow-ai/cmux/pull/741)) +- Various drag-handle crash fixes and reentrancy guards +- Background workspace git metadata refresh after external checkout +- Markdown panel text click focus ([#991](https://github.com/manaflow-ai/cmux/pull/991)) +- Browser Cmd+F overlay clipping in portal mode ([#916](https://github.com/manaflow-ai/cmux/pull/916)) +- Voice dictation text insertion ([#857](https://github.com/manaflow-ai/cmux/pull/857)) +- Browser panel lifecycle after WebContent process termination ([#892](https://github.com/manaflow-ai/cmux/pull/892)) +- Typing lag reduction by hiding invisible views from the accessibility tree ([#862](https://github.com/manaflow-ai/cmux/pull/862)) +- CJK font fallback preventing decorative font rendering for CJK characters ([#1017](https://github.com/manaflow-ai/cmux/pull/1017)) +- Inline VS Code serve-web token exposure via argv ([#1033](https://github.com/manaflow-ai/cmux/pull/1033)) +- Browser pane portal anchor sizing ([#1094](https://github.com/manaflow-ai/cmux/pull/1094)) +- Pinned workspace notification reordering ([#1116](https://github.com/manaflow-ai/cmux/pull/1116)) +- cmux --version memory blowup ([#1121](https://github.com/manaflow-ai/cmux/pull/1121)) +- Notification ring dismissal on direct terminal clicks ([#1126](https://github.com/manaflow-ai/cmux/pull/1126)) +- Browser portal visibility when terminal tab is active ([#1130](https://github.com/manaflow-ai/cmux/pull/1130)) +- Browser panes reloading when switching workspaces ([#1136](https://github.com/manaflow-ai/cmux/pull/1136)) +- Sidebar PR badge detection ([#1139](https://github.com/manaflow-ai/cmux/pull/1139)) +- Browser address bar disappearing during pane zoom ([#1145](https://github.com/manaflow-ai/cmux/pull/1145)) +- Ghost terminal surface focus after split close ([#1148](https://github.com/manaflow-ai/cmux/pull/1148)) +- Browser DevTools resize loop and layout stability ([#1170](https://github.com/manaflow-ai/cmux/pull/1170), [#1173](https://github.com/manaflow-ai/cmux/pull/1173), [#1189](https://github.com/manaflow-ai/cmux/pull/1189)) +- Typing lag from sidebar re-evaluation and hitTest overhead ([#1204](https://github.com/manaflow-ai/cmux/issues/1204)) +- Browser pane stale content after drag splits ([#1215](https://github.com/manaflow-ai/cmux/pull/1215)) +- Terminal drop overlay misplacement during drag hover ([#1213](https://github.com/manaflow-ai/cmux/pull/1213)) +- Hidden browser slot inspector focus crash ([#1211](https://github.com/manaflow-ai/cmux/pull/1211)) +- Browser devtools hide fallback ([#1220](https://github.com/manaflow-ai/cmux/pull/1220)) +- Browser portal refresh on geometry churn ([#1224](https://github.com/manaflow-ai/cmux/pull/1224)) +- Browser tab switch triggering unnecessary reload ([#1228](https://github.com/manaflow-ai/cmux/pull/1228)) +- Devtools side dock guard for attached devtools ([#1230](https://github.com/manaflow-ai/cmux/pull/1230)) + +### Thanks to 24 contributors! +- [@0xble](https://github.com/0xble) +- [@afxjzs](https://github.com/afxjzs) +- [@AI-per](https://github.com/AI-per) +- [@atani](https://github.com/atani) +- [@atmigtnca](https://github.com/atmigtnca) +- [@austinywang](https://github.com/austinywang) +- [@cheulyop](https://github.com/cheulyop) +- [@ConnorCallison](https://github.com/ConnorCallison) +- [@gonzaloserrano](https://github.com/gonzaloserrano) +- [@harukitosa](https://github.com/harukitosa) +- [@homanp](https://github.com/homanp) +- [@JLeeChan](https://github.com/JLeeChan) +- [@josemasri](https://github.com/josemasri) +- [@lawrencecchen](https://github.com/lawrencecchen) +- [@novarii](https://github.com/novarii) +- [@orkhanrz](https://github.com/orkhanrz) +- [@qianwan](https://github.com/qianwan) +- [@rjwittams](https://github.com/rjwittams) +- [@sminamot](https://github.com/sminamot) +- [@tmcarr](https://github.com/tmcarr) +- [@trydis](https://github.com/trydis) +- [@ukoasis](https://github.com/ukoasis) +- [@y-agatsuma](https://github.com/y-agatsuma) +- [@yasunogithub](https://github.com/yasunogithub) + +## [0.61.0] - 2026-02-25 + +### Added +- Command palette (Cmd+Shift+P) with update actions and all-window switcher results ([#358](https://github.com/manaflow-ai/cmux/pull/358), [#361](https://github.com/manaflow-ai/cmux/pull/361)) +- Split actions and shortcut hints in terminal context menus +- Cross-window tab and workspace move UI with improved destination focus behavior +- Sidebar pull request metadata rows and workspace PR open actions +- Workspace color schemes and left-rail workspace indicator settings ([#324](https://github.com/manaflow-ai/cmux/pull/324), [#329](https://github.com/manaflow-ai/cmux/pull/329), [#332](https://github.com/manaflow-ai/cmux/pull/332)) +- URL open-wrapper routing into the embedded browser ([#332](https://github.com/manaflow-ai/cmux/pull/332)) +- Cmd+Q quit warning with suppression toggle ([#295](https://github.com/manaflow-ai/cmux/pull/295)) +- `cmux --version` output now includes commit metadata + +### Changed +- Added light mode and unified theme refresh across app surfaces ([#258](https://github.com/manaflow-ai/cmux/pull/258)) — thanks @ijpatricio for the report! +- Browser link middle-click handling now uses native WebKit behavior ([#416](https://github.com/manaflow-ai/cmux/pull/416)) +- Settings-window actions now route through a single command-palette/settings flow +- Sentry upgraded with tracing, breadcrumbs, and dSYM upload support ([#366](https://github.com/manaflow-ai/cmux/pull/366)) +- Session restore scope clarification: cmux restores layout, working directory, scrollback, and browser history, but does not resume live terminal process state yet + +### Fixed +- Startup split hang when pressing Cmd+D then Ctrl+D early after launch ([#364](https://github.com/manaflow-ai/cmux/pull/364)) +- Browser focus handoff and click-to-focus regressions in mixed terminal/browser workspaces ([#381](https://github.com/manaflow-ai/cmux/pull/381), [#355](https://github.com/manaflow-ai/cmux/pull/355)) +- Caps Lock handling in browser omnibar keyboard paths ([#382](https://github.com/manaflow-ai/cmux/pull/382)) +- Embedded browser deeplink URL scheme handling ([#392](https://github.com/manaflow-ai/cmux/pull/392)) +- Sidebar resize cap regression ([#393](https://github.com/manaflow-ai/cmux/pull/393)) +- Terminal zoom inheritance for new splits, surfaces, and workspaces ([#384](https://github.com/manaflow-ai/cmux/pull/384)) +- Terminal find overlay layering across split and portal-hosted layouts +- Titlebar drag and double-click zoom handling on browser-side panes +- Stale browser favicon and window-title updates after navigation + +### Thanks to 7 contributors! +- [@austinywang](https://github.com/austinywang) +- [@avisser](https://github.com/avisser) +- [@gnguralnick](https://github.com/gnguralnick) +- [@ijpatricio](https://github.com/ijpatricio) +- [@jperkin](https://github.com/jperkin) +- [@jungcome7](https://github.com/jungcome7) +- [@lawrencecchen](https://github.com/lawrencecchen) + ## [0.60.0] - 2026-02-21 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index bc3d5bba..0fcbfce3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,16 +16,40 @@ After making code changes, always run the reload script with a tag to launch the ./scripts/reload.sh --tag fix-zsh-autosuggestions ``` -After making code changes, always run the build: +When reporting a tagged reload result in chat, use the format for your agent type: + +**Claude Code** (markdown link with correct derived-data path, cmd+clickable): +```markdown +======================================================= +[cmux DEV .app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-/Build/Products/Debug/cmux%20DEV%20.app) +======================================================= +``` + +**Codex** (plain text format): +``` +======================================================= +[: file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-/Build/Products/Debug/cmux%20DEV%20.app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-/Build/Products/Debug/cmux%20DEV%20.app) +======================================================= +``` + +Never use `/tmp/cmux-/...` app links in chat output. If the expected DerivedData path is missing, resolve the real `.app` path and report that `file://` URL. + +After making code changes, always use `reload.sh --tag` to build and launch. **Never run bare `xcodebuild` or `open` an untagged `cmux DEV.app`.** Untagged builds share the default debug socket and bundle ID with other agents, causing conflicts and stealing focus. ```bash -xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build +./scripts/reload.sh --tag +``` + +If you only need to verify the build compiles (no launch), use a tagged derivedDataPath: + +```bash +xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux- build ``` When rebuilding GhosttyKit.xcframework, always use Release optimizations: ```bash -cd ghostty && zig build -Demit-xcframework=true -Doptimize=ReleaseFast +cd ghostty && zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast ``` When rebuilding cmuxd for release/bundling, always use ReleaseFast: @@ -89,11 +113,35 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - Focus events: `focus.panel`, `focus.bonsplit`, `focus.firstResponder`, `focus.moveFocus` - Bonsplit events: `tab.select`, `tab.close`, `tab.dragStart`, `tab.drop`, `pane.focus`, `pane.drop`, `divider.dragStart` +## Regression test commit policy + +When adding a regression test for a bug fix, use a two-commit structure so CI proves the test catches the bug: + +1. **Commit 1:** Add the failing test only (no fix). CI should go red. +2. **Commit 2:** Add the fix. CI should go green. + +This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the test genuinely fails without the fix. + ## Pitfalls - **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`). - Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag. +- **Typing-latency-sensitive paths** (read carefully before touching these areas): + - `WindowTerminalHostView.hitTest()` in `TerminalWindowPortal.swift`: called on every event including keyboard. All divider/sidebar/drag routing is gated to pointer events only. Do not add work outside the `isPointerEvent` guard. + - `TabItemView` in `ContentView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add `@EnvironmentObject`, `@ObservedObject` (besides `tab`), or `@Binding` properties without updating the `==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use the precomputed `let` parameters instead. + - `TerminalSurface.forceRefresh()` in `GhosttyTerminalView.swift`: called on every keystroke. Do not add allocations, file I/O, or formatting here. +- **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn. - **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd && git merge-base --is-ancestor HEAD origin/main`. +- **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc. + +## Test quality policy + +- Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns. +- Do not add tests that read checked-in metadata or project files such as `Resources/Info.plist`, `project.pbxproj`, `.xcconfig`, or source files only to assert that a key, string, plist entry, or snippet exists. +- Tests must verify observable runtime behavior through executable paths (unit/integration/e2e/CLI), not implementation shape. +- For metadata changes, prefer verifying the built app bundle or the runtime behavior that depends on that metadata, not the checked-in source file. +- If a behavior cannot be exercised end-to-end yet, add a small runtime seam or harness first, then test through that seam. +- If no meaningful behavioral or artifact-level test is practical, skip the fake regression test and state that explicitly. ## Socket command threading policy @@ -111,21 +159,14 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents). - All non-focus commands should preserve current user focus context while still applying data/model changes. -## E2E mac UI tests +## Testing policy -Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`: +**Never run tests locally.** All tests (E2E, UI, python socket tests) run via GitHub Actions or on the VM. -```bash -ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test' -``` - -## Basic tests - -Run basic automated tests on the UTM macOS VM (never on the host machine): - -```bash -ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmux DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit) && open "$APP" --env CMUX_SOCKET_MODE=allowAll && for i in {1..20}; do [ -S /tmp/cmux-debug.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py' -``` +- **E2E / UI tests:** trigger via `gh workflow run test-e2e.yml` (see cmuxterm-hq CLAUDE.md for details) +- **Unit tests:** `xcodebuild -scheme cmux-unit` is safe (no app launch), but prefer CI +- **Python socket tests (tests_v2/):** these connect to a running cmux instance's socket. Never launch an untagged `cmux DEV.app` to run them. If you must test locally, use a tagged build's socket (`/tmp/cmux-debug-.sock`) with `CMUX_SOCKET=/tmp/cmux-debug-.sock` +- **Never `open` an untagged `cmux DEV.app`** from DerivedData. It conflicts with the user's running debug instance. ## Ghostty submodule workflow @@ -164,7 +205,7 @@ git commit -m "Update ghostty submodule" Use the `/release` command to prepare a new release. This will: 1. Determine the new version (bumps minor by default) 2. Gather commits since the last tag and update the changelog -3. Update `CHANGELOG.md` and `docs-site/content/docs/changelog.mdx` +3. Update `CHANGELOG.md` (the docs changelog page at `web/app/docs/changelog/page.tsx` reads from it) 4. Run `./scripts/bump-version.sh` to update both versions 5. Commit, tag, and push @@ -193,4 +234,4 @@ Notes: - The release asset is `cmux-macos.dmg` attached to the tag. - README download button points to `releases/latest/download/cmux-macos.dmg`. - Versioning: bump the minor version for updates unless explicitly asked otherwise. -- Changelog: always update both `CHANGELOG.md` and the docs-site version. +- Changelog: update `CHANGELOG.md`; docs changelog is rendered from it. diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c7d6b8d6..8346d1ab 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,6 +1,8 @@ import Foundation import Darwin -import Security +#if canImport(Sentry) +import Sentry +#endif struct CLIError: Error, CustomStringConvertible { let message: String @@ -8,6 +10,182 @@ struct CLIError: Error, CustomStringConvertible { var description: String { message } } +private final class CLISocketSentryTelemetry { + private let command: String + private let subcommand: String + private let socketPath: String + private let envSocketPath: String? + private let workspaceId: String? + private let surfaceId: String? + private let disabledByEnv: Bool + +#if canImport(Sentry) + private static let startupLock = NSLock() + private static var started = false + private static let dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" +#endif + + init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) { + self.command = command.lowercased() + self.subcommand = commandArgs.first?.lowercased() ?? "help" + self.socketPath = socketPath + self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] ?? processEnv["CMUX_SOCKET"] + self.workspaceId = processEnv["CMUX_WORKSPACE_ID"] + self.surfaceId = processEnv["CMUX_SURFACE_ID"] + self.disabledByEnv = + processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" || + processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1" + } + + func breadcrumb(_ message: String, data: [String: Any] = [:]) { + guard shouldEmit else { return } +#if canImport(Sentry) + Self.ensureStarted() + var payload = baseContext() + for (key, value) in data { + payload[key] = value + } + let crumb = Breadcrumb(level: .info, category: "cmux.cli") + crumb.message = message + crumb.data = payload + SentrySDK.addBreadcrumb(crumb) +#endif + } + + func captureError(stage: String, error: Error) { + guard shouldEmit else { return } +#if canImport(Sentry) + Self.ensureStarted() + var context = baseContext() + context["stage"] = stage + context["error"] = String(describing: error) + for (key, value) in socketDiagnostics() { + context[key] = value + } + let subcommand = self.subcommand + let command = self.command + _ = SentrySDK.capture(error: error) { scope in + scope.setLevel(.error) + scope.setTag(value: "cmux-cli", key: "component") + scope.setTag(value: command, key: "cli_command") + scope.setTag(value: subcommand, key: "cli_subcommand") + scope.setContext(value: context, key: "cli_socket") + } + SentrySDK.flush(timeout: 2.0) +#endif + } + + private var shouldEmit: Bool { + !disabledByEnv + } + + private func baseContext() -> [String: Any] { + var context: [String: Any] = [ + "command": command, + "subcommand": subcommand, + "requested_socket_path": socketPath, + "env_socket_path": envSocketPath ?? "" + ] + if let workspaceId { + context["workspace_id"] = workspaceId + } + if let surfaceId { + context["surface_id"] = surfaceId + } + return context + } + + private func socketDiagnostics() -> [String: Any] { + var context: [String: Any] = [ + "cwd": FileManager.default.currentDirectoryPath, + "uid": Int(getuid()), + "euid": Int(geteuid()) + ] + + var st = stat() + if lstat(socketPath, &st) == 0 { + context["socket_exists"] = true + context["socket_mode"] = String(format: "%o", Int(st.st_mode & 0o7777)) + context["socket_owner_uid"] = Int(st.st_uid) + context["socket_owner_gid"] = Int(st.st_gid) + context["socket_file_type"] = Self.fileTypeDescription(mode: st.st_mode) + } else { + let code = errno + context["socket_exists"] = false + context["socket_errno"] = Int(code) + context["socket_errno_description"] = String(cString: strerror(code)) + } + + let tmpSockets = Self.discoverTmpCmuxSockets(limit: 10) + if !tmpSockets.isEmpty { + context["tmp_cmux_sockets"] = tmpSockets + } + let taggedSockets = tmpSockets.filter { $0 != "/tmp/cmux.sock" } + if socketPath == "/tmp/cmux.sock", + (envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true), + !taggedSockets.isEmpty { + context["possible_root_cause"] = "CMUX_SOCKET_PATH/CMUX_SOCKET missing while tagged sockets exist" + } + + return context + } + + private static func fileTypeDescription(mode: mode_t) -> String { + switch mode & mode_t(S_IFMT) { + case mode_t(S_IFSOCK): + return "socket" + case mode_t(S_IFREG): + return "regular" + case mode_t(S_IFDIR): + return "directory" + case mode_t(S_IFLNK): + return "symlink" + default: + return "other" + } + } + + private static func discoverTmpCmuxSockets(limit: Int) -> [String] { + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else { + return [] + } + var sockets: [String] = [] + for name in entries.sorted() { + guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue } + let fullPath = "/tmp/\(name)" + var st = stat() + guard lstat(fullPath, &st) == 0 else { continue } + guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue } + sockets.append(fullPath) + if sockets.count >= limit { + break + } + } + return sockets + } + +#if canImport(Sentry) + private static func ensureStarted() { + startupLock.lock() + defer { startupLock.unlock() } + guard !started else { return } + SentrySDK.start { options in + options.dsn = dsn +#if DEBUG + options.environment = "development-cli" +#else + options.environment = "production-cli" +#endif + options.debug = false + options.sendDefaultPii = true + options.attachStacktrace = true + options.tracesSampleRate = 0.0 + } + started = true + } +#endif +} + struct WindowInfo { let index: Int let id: String @@ -237,17 +415,17 @@ enum CLIIDFormat: String { } private enum SocketPasswordResolver { - private static let service = "com.cmuxterm.app.socket-control" - private static let account = "local-socket-password" + private static let directoryName = "cmux" + private static let fileName = "socket-control-password" static func resolve(explicit: String?) -> String? { - if let explicit = normalized(explicit), !explicit.isEmpty { + if let explicit = normalized(explicit) { return explicit } - if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]), !env.isEmpty { + if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) { return env } - return loadFromKeychain() + return loadFromFile() } private static func normalized(_ value: String?) -> String? { @@ -256,29 +434,176 @@ private enum SocketPasswordResolver { return trimmed.isEmpty ? nil : trimmed } - private static func loadFromKeychain() -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess else { + private static func loadFromFile() -> String? { + guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } - guard let data = result as? Data else { + let passwordURL = appSupport + .appendingPathComponent(directoryName, isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + guard let data = try? Data(contentsOf: passwordURL) else { return nil } - return String(data: data, encoding: .utf8) + guard let value = String(data: data, encoding: .utf8) else { + return nil + } + return normalized(value) + } +} + +private enum CLISocketPathSource { + case explicitFlag + case environment + case implicitDefault +} + +private enum CLISocketPathResolver { + static let defaultSocketPath = "/tmp/cmux.sock" + private static let fallbackSocketPath = "/tmp/cmux-debug.sock" + private static let stagingSocketPath = "/tmp/cmux-staging.sock" + private static let lastSocketPathFile = "/tmp/cmux-last-socket-path" + + static func resolve( + requestedPath: String, + source: CLISocketPathSource, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + guard source == .implicitDefault else { + return requestedPath + } + + let candidates = dedupe(candidatePaths(requestedPath: requestedPath, environment: environment)) + + // Prefer sockets that are currently accepting connections. + for path in candidates where canConnect(to: path) { + return path + } + + // If the listener is still starting, prefer existing socket files. + for path in candidates where isSocketFile(path) { + return path + } + + return requestedPath + } + + private static func candidatePaths(requestedPath: String, environment: [String: String]) -> [String] { + var candidates: [String] = [] + + if let tag = normalized(environment["CMUX_TAG"]) { + let slug = sanitizeTagSlug(tag) + candidates.append("/tmp/cmux-debug-\(slug).sock") + candidates.append("/tmp/cmux-\(slug).sock") + } + + candidates.append(requestedPath) + candidates.append(fallbackSocketPath) + candidates.append(stagingSocketPath) + candidates.append(contentsOf: discoverTaggedSockets(limit: 12)) + if let last = readLastSocketPath() { + candidates.append(last) + } + return candidates + } + + private static func readLastSocketPath() -> String? { + guard let data = try? String(contentsOfFile: lastSocketPathFile, encoding: .utf8) else { + return nil + } + return normalized(data) + } + + private static func discoverTaggedSockets(limit: Int) -> [String] { + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else { + return [] + } + + var discovered: [(path: String, mtime: TimeInterval)] = [] + discovered.reserveCapacity(min(limit, entries.count)) + for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") { + let path = "/tmp/\(name)" + var st = stat() + guard lstat(path, &st) == 0 else { continue } + guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue } + if path == defaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath { + continue + } + let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000 + discovered.append((path: path, mtime: modified)) + } + + discovered.sort { $0.mtime > $1.mtime } + return discovered.prefix(limit).map(\.path) + } + + private static func isSocketFile(_ path: String) -> Bool { + var st = stat() + return lstat(path, &st) == 0 && (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) + } + + private static func canConnect(to path: String) -> Bool { + guard isSocketFile(path) else { return false } + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { Darwin.close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(buf, ptr, maxLength - 1) + } + } + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + + private static func sanitizeTagSlug(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let slug = trimmed + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .replacingOccurrences(of: "-+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return slug.isEmpty ? "agent" : slug + } + + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func dedupe(_ paths: [String]) -> [String] { + var seen: Set = [] + var ordered: [String] = [] + ordered.reserveCapacity(paths.count) + for path in paths where !path.isEmpty { + if seen.insert(path).inserted { + ordered.append(path) + } + } + return ordered } } final class SocketClient { private let path: String private var socketFD: Int32 = -1 + private static let connectRetryWindowSeconds: TimeInterval = 2.0 + private static let connectRetryIntervalSeconds: TimeInterval = 0.1 + private static let retriableConnectErrnos: Set = [ + ENOENT, + ECONNREFUSED, + EAGAIN, + EINTR + ] private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0 private static let responseTimeoutSeconds: TimeInterval = { let env = ProcessInfo.processInfo.environment @@ -297,40 +622,68 @@ final class SocketClient { func connect() throws { if socketFD >= 0 { return } - // Verify socket is owned by the current user to prevent fake-socket attacks - var st = stat() - guard stat(path, &st) == 0 else { - throw CLIError(message: "Socket not found at \(path)") - } - guard st.st_uid == getuid() else { - throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") - } + let deadline = Date().addingTimeInterval(Self.connectRetryWindowSeconds) + var lastError: CLIError? - socketFD = socket(AF_UNIX, SOCK_STREAM, 0) - if socketFD < 0 { - throw CLIError(message: "Failed to create socket") - } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let maxLength = MemoryLayout.size(ofValue: addr.sun_path) - path.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - strncpy(buf, ptr, maxLength - 1) + while true { + // Verify socket is owned by the current user to prevent fake-socket attacks. + var st = stat() + guard stat(path, &st) == 0 else { + let error = CLIError(message: "Socket not found at \(path)") + lastError = error + if errno == ENOENT, Date() < deadline { + Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds) + continue + } + throw error } - } - - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { + throw CLIError(message: "Path exists at \(path) but is not a Unix socket") } - } - if result != 0 { + guard st.st_uid == getuid() else { + throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") + } + + socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + if socketFD < 0 { + throw CLIError(message: "Failed to create socket") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(buf, ptr, maxLength - 1) + } + } + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { + return + } + + let connectErrno = errno Darwin.close(socketFD) socketFD = -1 - throw CLIError(message: "Failed to connect to socket at \(path)") + + let error = CLIError( + message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" + ) + lastError = error + if Self.retriableConnectErrnos.contains(connectErrno), Date() < deadline { + Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds) + continue + } + throw error } + + throw lastError ?? CLIError(message: "Failed to connect to socket at \(path)") } func close() { @@ -439,7 +792,24 @@ struct CMUXCLI { let args: [String] func run() throws { - var socketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmux.sock" + let processEnv = ProcessInfo.processInfo.environment + let envSocketPath: String? = { + for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] { + guard let raw = processEnv[key] else { continue } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return nil + }() + var socketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath + var socketPathSource: CLISocketPathSource + if let envSocketPath { + socketPathSource = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment + } else { + socketPathSource = .implicitDefault + } var jsonOutput = false var idFormatArg: String? = nil var windowId: String? = nil @@ -453,6 +823,7 @@ struct CMUXCLI { throw CLIError(message: "--socket requires a path") } socketPath = args[index + 1] + socketPathSource = .explicitFlag index += 2 continue } @@ -503,31 +874,103 @@ struct CMUXCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) + let cliTelemetry = CLISocketSentryTelemetry( + command: command, + commandArgs: commandArgs, + socketPath: socketPath, + processEnv: processEnv + ) + let resolvedSocketPath = CLISocketPathResolver.resolve( + requestedPath: socketPath, + source: socketPathSource, + environment: processEnv + ) if command == "version" { print(versionSummary()) return } + // If the argument looks like a path (not a known command), open a workspace there. + if looksLikePath(command) { + try openPath(command, socketPath: resolvedSocketPath) + return + } + // Check for --help/-h on subcommands before connecting to the socket, // so help text is available even when cmux is not running. - if commandArgs.contains("--help") || commandArgs.contains("-h") { + if command != "__tmux-compat", + command != "claude-teams", + (commandArgs.contains("--help") || commandArgs.contains("-h")) { if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) { return } + print("Unknown command '\(command)'. Run 'cmux help' to see available commands.") + return } - let client = SocketClient(path: socketPath) - try client.connect() + if command == "welcome" { + printWelcome() + return + } + + if command == "shortcuts" { + try runShortcuts( + commandArgs: commandArgs, + socketPath: resolvedSocketPath, + explicitPassword: socketPasswordArg, + jsonOutput: jsonOutput + ) + return + } + + if command == "feedback" { + try runFeedback( + commandArgs: commandArgs, + socketPath: resolvedSocketPath, + explicitPassword: socketPasswordArg, + jsonOutput: jsonOutput + ) + return + } + + if command == "claude-teams" { + try runClaudeTeams( + commandArgs: commandArgs, + socketPath: resolvedSocketPath, + explicitPassword: socketPasswordArg + ) + return + } + + let client = SocketClient(path: resolvedSocketPath) + if resolvedSocketPath != socketPath { + cliTelemetry.breadcrumb( + "socket.path.autodiscovered", + data: [ + "requested_path": socketPath, + "resolved_path": resolvedSocketPath + ] + ) + } + cliTelemetry.breadcrumb( + "socket.connect.attempt", + data: [ + "command": command, + "path": resolvedSocketPath + ] + ) + do { + try client.connect() + cliTelemetry.breadcrumb("socket.connect.success", data: ["path": resolvedSocketPath]) + } catch { + cliTelemetry.breadcrumb("socket.connect.failure", data: ["path": resolvedSocketPath]) + cliTelemetry.captureError(stage: "socket_connect", error: error) + throw error + } defer { client.close() } - if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { - let authResponse = try client.send(command: "auth \(socketPassword)") - if authResponse.hasPrefix("ERROR:"), - !authResponse.contains("Unknown command 'auth'") { - throw CLIError(message: authResponse) - } - } + try authenticateClientIfNeeded(client, explicitPassword: socketPasswordArg) let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) @@ -681,22 +1124,25 @@ struct CMUXCLI { } case "new-workspace": - let (commandOpt, remaining) = parseOption(commandArgs, name: "--command") + let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") + let (cwdOpt, remaining) = parseOption(rem0, name: "--cwd") if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { - throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command ") + throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command , --cwd ") } - let response = try sendV1Command("new_workspace", client: client) - print(response) - if let commandText = commandOpt { - guard response.hasPrefix("OK ") else { - throw CLIError(message: "new-workspace failed, cannot run --command") - } - let wsId = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + var params: [String: Any] = [:] + if let cwdOpt { + let resolved = resolvePath(cwdOpt) + params["cwd"] = resolved + } + let response = try client.sendV2(method: "workspace.create", params: params) + let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" + print("OK \(wsId)") + if let commandText = commandOpt, !wsId.isEmpty { // Wait for shell to initialize Thread.sleep(forTimeInterval: 0.5) let text = unescapeSendText(commandText + "\\n") - let params: [String: Any] = ["text": text, "workspace_id": wsId] - _ = try client.sendV2(method: "surface.send_text", params: params) + let sendParams: [String: Any] = ["text": text, "workspace_id": wsId] + _ = try client.sendV2(method: "surface.send_text", params: sendParams) } case "new-split": @@ -767,6 +1213,9 @@ struct CMUXCLI { } } + case "tree": + try runTreeCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + case "focus-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) guard let paneRaw = optionValue(commandArgs, name: "--pane") ?? commandArgs.first else { @@ -1099,11 +1548,131 @@ struct CMUXCLI { } case "clear-notifications": - let response = try sendV1Command("clear_notifications", client: client) + var socketCmd = "clear_notifications" + if let wsFlag = optionValue(commandArgs, name: "--workspace") { + let wsId = try resolveWorkspaceId(wsFlag, client: client) + socketCmd += " --tab=\(wsId)" + } else if windowId == nil, + let envWs = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"], + let wsId = try? resolveWorkspaceId(envWs, client: client) { + socketCmd += " --tab=\(wsId)" + } + let response = try sendV1Command(socketCmd, client: client) print(response) case "claude-hook": - try runClaudeHook(commandArgs: commandArgs, client: client) + cliTelemetry.breadcrumb("claude-hook.dispatch") + do { + try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) + cliTelemetry.breadcrumb("claude-hook.completed") + } catch { + cliTelemetry.breadcrumb("claude-hook.failure") + cliTelemetry.captureError(stage: "claude_hook_dispatch", error: error) + throw error + } + + case "set-status": + let (icon, r1) = parseOption(commandArgs, name: "--icon") + let (color, r2) = parseOption(r1, name: "--color") + let (wsFlag, r3) = parseOption(r2, name: "--workspace") + guard r3.count >= 2 else { + throw CLIError(message: "set-status requires and ") + } + let key = r3[0] + let value = r3.dropFirst().joined(separator: " ") + guard !value.isEmpty else { + throw CLIError(message: "set-status requires a non-empty value") + } + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + var socketCmd = "set_status \(key) \(socketQuote(value))" + if let icon { socketCmd += " --icon=\(socketQuote(icon))" } + if let color { socketCmd += " --color=\(socketQuote(color))" } + socketCmd += " --tab=\(wsId)" + let response = try sendV1Command(socketCmd, client: client) + print(response) + + case "clear-status": + let (wsFlag, csRemaining) = parseOption(commandArgs, name: "--workspace") + guard let key = csRemaining.first else { + throw CLIError(message: "clear-status requires a ") + } + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("clear_status \(key) --tab=\(wsId)", client: client) + print(response) + + case "list-status": + let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("list_status --tab=\(wsId)", client: client) + print(response) + + case "set-progress": + let (label, spR1) = parseOption(commandArgs, name: "--label") + let (wsFlag, spR2) = parseOption(spR1, name: "--workspace") + guard let valueStr = spR2.first else { + throw CLIError(message: "set-progress requires a progress value (0.0-1.0)") + } + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + var socketCmd = "set_progress \(valueStr)" + if let label { socketCmd += " --label=\(socketQuote(label))" } + socketCmd += " --tab=\(wsId)" + let response = try sendV1Command(socketCmd, client: client) + print(response) + + case "clear-progress": + let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("clear_progress --tab=\(wsId)", client: client) + print(response) + + case "log": + let (level, r1) = parseOption(commandArgs, name: "--level") + let (source, r2) = parseOption(r1, name: "--source") + let (wsFlag, r3) = parseOption(r2, name: "--workspace") + // Strip leading "--" separator if present + let positional = r3.first == "--" ? Array(r3.dropFirst()) : r3 + let message = positional.joined(separator: " ") + guard !message.isEmpty else { + throw CLIError(message: "log requires a message") + } + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + var socketCmd = "log" + if let level { socketCmd += " --level=\(level)" } + if let source { socketCmd += " --source=\(socketQuote(source))" } + socketCmd += " --tab=\(wsId) -- \(socketQuote(message))" + let response = try sendV1Command(socketCmd, client: client) + print(response) + + case "clear-log": + let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("clear_log --tab=\(wsId)", client: client) + print(response) + + case "list-log": + let (limitStr, r1) = parseOption(commandArgs, name: "--limit") + let (wsFlag, _) = parseOption(r1, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + var socketCmd = "list_log" + if let limitStr { socketCmd += " --limit=\(limitStr)" } + socketCmd += " --tab=\(wsId)" + let response = try sendV1Command(socketCmd, client: client) + print(response) + + case "sidebar-state": + let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let response = try sendV1Command("sidebar_state --tab=\(wsId)", client: client) + print(response) case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } @@ -1114,6 +1683,15 @@ struct CMUXCLI { let response = try sendV1Command("simulate_app_active", client: client) print(response) + case "__tmux-compat": + try runClaudeTeamsTmuxCompat( + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowId + ) + case "capture-pane", "resize-pane", "pipe-pane", @@ -1185,12 +1763,369 @@ struct CMUXCLI { let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface") try runBrowserCommand(commandArgs: ["is-webview-focused"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + // Markdown commands + case "markdown": + try runMarkdownCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + default: print(usage()) throw CLIError(message: "Unknown command: \(command)") } } + private func resolvePath(_ path: String) -> String { + let expanded = NSString(string: path).expandingTildeInPath + if expanded.hasPrefix("/") { return expanded } + let cwd = FileManager.default.currentDirectoryPath + return (cwd as NSString).appendingPathComponent(expanded) + } + + private func sanitizedFilenameComponent(_ raw: String) -> String { + let sanitized = raw.replacingOccurrences( + of: #"[^\p{L}\p{N}._-]+"#, + with: "-", + options: .regularExpression + ) + let trimmed = sanitized.trimmingCharacters(in: CharacterSet(charactersIn: "-.")) + return trimmed.isEmpty ? "item" : trimmed + } + + private func bestEffortPruneTemporaryFiles( + in directoryURL: URL, + keepingMostRecent maxCount: Int = 50, + maxAge: TimeInterval = 24 * 60 * 60 + ) { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + let now = Date() + let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]), + values.isRegularFile == true else { + return nil + } + return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast) + }.sorted { $0.date > $1.date } + + for (index, entry) in datedEntries.enumerated() { + if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge { + try? FileManager.default.removeItem(at: entry.url) + } + } + } + + // MARK: - Markdown Commands + + private func runMarkdownCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat + ) throws { + var args = commandArgs + + // Parse routing flags + let (workspaceOpt, argsAfterWorkspace) = parseOption(args, name: "--workspace") + let (windowOpt, argsAfterWindow) = parseOption(argsAfterWorkspace, name: "--window") + let (surfaceOpt, argsAfterSurface) = parseOption(argsAfterWindow, name: "--surface") + args = argsAfterSurface + + // Determine subcommand. Explicit "open" is supported, otherwise treat + // a single positional argument as shorthand path. + let subArgs: [String] + if let first = args.first, first.lowercased() == "open" { + subArgs = Array(args.dropFirst()) + } else if args.count == 1, let first = args.first, !first.hasPrefix("-") { + subArgs = [first] + } else { + // Allow path-like first tokens (e.g. plan.md) with trailing args + // so we can surface specific trailing-arg/flag errors below. + if let first = args.first, first.hasPrefix("-") { + throw CLIError( + message: + "markdown open: unknown flag '\(first)'. Usage: cmux markdown open [--workspace ] [--surface ] [--window ]" + ) + } else if let first = args.first, looksLikePath(first) || first.contains(".") { + subArgs = args + } else if let first = args.first { + throw CLIError(message: "Unknown markdown subcommand: \(first). Usage: cmux markdown open ") + } else { + subArgs = [] + } + } + + guard let rawPath = subArgs.first, !rawPath.isEmpty else { + throw CLIError(message: "markdown open requires a file path. Usage: cmux markdown open ") + } + let trailingArgs = Array(subArgs.dropFirst()) + if let unknownFlag = trailingArgs.first(where: { $0.hasPrefix("-") }) { + throw CLIError( + message: + "markdown open: unknown flag '\(unknownFlag)'. Usage: cmux markdown open [--workspace ] [--surface ] [--window ]" + ) + } + if let extraArg = trailingArgs.first { + throw CLIError( + message: + "markdown open: unexpected argument '\(extraArg)'. Usage: cmux markdown open [--workspace ] [--surface ] [--window ]" + ) + } + + let absolutePath = resolvePath(rawPath) + + // Build params + var params: [String: Any] = ["path": absolutePath] + if let surfaceRaw = surfaceOpt { + if let surface = try normalizeSurfaceHandle(surfaceRaw, client: client) { + params["surface_id"] = surface + } + } + let workspaceRaw = workspaceOpt ?? (windowOpt == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + if let workspaceRaw { + if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) { + params["workspace_id"] = workspace + } + } + if let windowRaw = windowOpt { + if let window = try normalizeWindowHandle(windowRaw, client: client) { + params["window_id"] = window + } + } + + let payload = try client.sendV2(method: "markdown.open", params: params) + + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown" + let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown" + let filePath = (payload["path"] as? String) ?? absolutePath + print("OK surface=\(surfaceText) pane=\(paneText) path=\(filePath)") + } + } + + /// Returns true if the argument looks like a filesystem path rather than a CLI command. + private func looksLikePath(_ arg: String) -> Bool { + if arg == "." || arg == ".." { return true } + if arg.hasPrefix("/") || arg.hasPrefix("./") || arg.hasPrefix("../") || arg.hasPrefix("~") { return true } + if arg.contains("/") { return true } + return false + } + + /// Open a path in cmux by creating a new workspace with the given directory. + /// Launches the app if it isn't already running. + private func openPath(_ path: String, socketPath: String) throws { + let resolved = resolvePath(path) + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: resolved, isDirectory: &isDir) + + let directory: String + if exists && isDir.boolValue { + directory = resolved + } else if exists { + // It's a file; use its parent directory + directory = (resolved as NSString).deletingLastPathComponent + } else { + throw CLIError(message: "Path does not exist: \(resolved)") + } + + // Try connecting to the socket. If it fails, launch the app and retry. + let client = SocketClient(path: socketPath) + if (try? client.connect()) == nil { + client.close() + try launchApp() + // Poll until socket accepts connections (up to 10 seconds) + let pollClient = SocketClient(path: socketPath) + var connected = false + for _ in 0..<100 { + if (try? pollClient.connect()) != nil { + connected = true + break + } + pollClient.close() + Thread.sleep(forTimeInterval: 0.1) + } + guard connected else { + throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") + } + // Use pollClient since it's connected + defer { pollClient.close() } + let params: [String: Any] = ["cwd": directory] + let response = try pollClient.sendV2(method: "workspace.create", params: params) + let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" + if !wsRef.isEmpty { + print("OK \(wsRef)") + } + try activateApp() + return + } + defer { client.close() } + + let params: [String: Any] = ["cwd": directory] + let response = try client.sendV2(method: "workspace.create", params: params) + let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" + if !wsRef.isEmpty { + print("OK \(wsRef)") + } + + // Bring the app to front + try activateApp() + } + + private func runFeedback( + commandArgs: [String], + socketPath: String, + explicitPassword: String?, + jsonOutput: Bool + ) throws { + let (emailOpt, rem0) = parseOption(commandArgs, name: "--email") + let (bodyOpt, rem1) = parseOption(rem0, name: "--body") + let (imagePaths, rem2) = parseRepeatedOption(rem1, name: "--image") + let remaining = rem2.filter { $0 != "--" } + + if let unknown = remaining.first { + throw CLIError(message: "feedback: unknown flag '\(unknown)'. Known flags: --email , --body , --image ") + } + + let client = try connectClient( + socketPath: socketPath, + explicitPassword: explicitPassword, + launchIfNeeded: true + ) + defer { client.close() } + + if emailOpt == nil && bodyOpt == nil && imagePaths.isEmpty { + var params: [String: Any] = [:] + let env = ProcessInfo.processInfo.environment + if let workspaceId = env["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !workspaceId.isEmpty { + params["workspace_id"] = workspaceId + params["activate"] = false + } else { + params["activate"] = true + } + let response = try client.sendV2(method: "feedback.open", params: params) + if jsonOutput { + print(jsonString(response)) + } else { + print("OK") + } + return + } + + guard let email = emailOpt?.trimmingCharacters(in: .whitespacesAndNewlines), + email.isEmpty == false else { + throw CLIError(message: "feedback requires --email when sending feedback") + } + guard let body = bodyOpt, body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw CLIError(message: "feedback requires --body when sending feedback") + } + + let resolvedImages = imagePaths.map(resolvePath) + let response = try client.sendV2(method: "feedback.submit", params: [ + "email": email, + "body": body, + "image_paths": resolvedImages, + ]) + if jsonOutput { + print(jsonString(response)) + } else { + print("OK") + } + } + + private func runShortcuts( + commandArgs: [String], + socketPath: String, + explicitPassword: String?, + jsonOutput: Bool + ) throws { + let remaining = commandArgs.filter { $0 != "--" } + if let unknown = remaining.first { + throw CLIError(message: "shortcuts: unknown flag '\(unknown)'") + } + + let client = try connectClient( + socketPath: socketPath, + explicitPassword: explicitPassword, + launchIfNeeded: true + ) + defer { client.close() } + + let response = try client.sendV2(method: "settings.open", params: [ + "target": "keyboardShortcuts", + "activate": true, + ]) + if jsonOutput { + print(jsonString(response)) + } else { + print("OK") + } + } + + private func connectClient( + socketPath: String, + explicitPassword: String?, + launchIfNeeded: Bool + ) throws -> SocketClient { + let client = SocketClient(path: socketPath) + if launchIfNeeded && (try? client.connect()) == nil { + client.close() + try launchApp() + + let pollClient = SocketClient(path: socketPath) + var connected = false + for _ in 0..<100 { + if (try? pollClient.connect()) != nil { + connected = true + break + } + pollClient.close() + Thread.sleep(forTimeInterval: 0.1) + } + guard connected else { + throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") + } + try authenticateClientIfNeeded(pollClient, explicitPassword: explicitPassword) + return pollClient + } + + try client.connect() + try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + return client + } + + private func authenticateClientIfNeeded(_ client: SocketClient, explicitPassword: String?) throws { + if let socketPassword = SocketPasswordResolver.resolve(explicit: explicitPassword) { + let authResponse = try client.send(command: "auth \(socketPassword)") + if authResponse.hasPrefix("ERROR:"), + !authResponse.contains("Unknown command 'auth'") { + throw CLIError(message: authResponse) + } + } + } + + private func launchApp() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-a", "cmux"] + try process.run() + process.waitUntilExit() + } + + private func activateApp() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-a", "cmux"] + try process.run() + process.waitUntilExit() + } + private func sendV1Command(_ command: String, client: SocketClient) throws -> String { let response = try client.send(command: command) if response.hasPrefix("ERROR:") { @@ -1864,7 +2799,34 @@ struct CMUXCLI { throw CLIError(message: "browser requires a subcommand") } - let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(commandArgs, name: "--surface") + var effectiveJSONOutput = jsonOutput + var effectiveIDFormat = idFormat + var browserArgs = commandArgs + + // Browser-skill examples often place output flags at the end of the command. + // Strip trailing display flags so they don't become part of a URL or selector. + while !browserArgs.isEmpty { + if browserArgs.last == "--json" { + effectiveJSONOutput = true + browserArgs.removeLast() + continue + } + + if browserArgs.count >= 2, + browserArgs[browserArgs.count - 2] == "--id-format" { + let raw = browserArgs.last! + guard let parsed = try CLIIDFormat.parse(raw) else { + throw CLIError(message: "--id-format must be one of: refs, uuids, both") + } + effectiveIDFormat = parsed + browserArgs.removeLast(2) + continue + } + + break + } + + let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(browserArgs, name: "--surface") var surfaceRaw = surfaceOpt var args = argsWithoutSurfaceFlag @@ -1893,8 +2855,8 @@ struct CMUXCLI { } func output(_ payload: [String: Any], fallback: String) { - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) return } print(fallback) @@ -1904,6 +2866,82 @@ struct CMUXCLI { } } + func displaySnapshotText(_ payload: [String: Any]) -> String { + let snapshotText = (payload["snapshot"] as? String) ?? "Empty page" + guard snapshotText.contains("\n- (empty)") else { + return snapshotText + } + + let url = ((payload["url"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let readyState = ((payload["ready_state"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + var lines = [snapshotText] + + if !url.isEmpty { + lines.append("url: \(url)") + } + if !readyState.isEmpty { + lines.append("ready_state: \(readyState)") + } + if url.isEmpty || url == "about:blank" { + lines.append("hint: run 'cmux browser get url' to verify navigation") + } + + return lines.joined(separator: "\n") + } + + func displayBrowserValue(_ value: Any) -> String { + if let dict = value as? [String: Any], + let type = dict["__cmux_t"] as? String, + type == "undefined" { + return "undefined" + } + if value is NSNull { + return "null" + } + if let string = value as? String { + return string + } + if let bool = value as? Bool { + return bool ? "true" : "false" + } + if let number = value as? NSNumber { + return number.stringValue + } + if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]), + let text = String(data: data, encoding: .utf8) { + return text + } + return String(describing: value) + } + + func displayBrowserLogItems(_ value: Any?) -> String? { + guard let items = value as? [Any], !items.isEmpty else { + return nil + } + + let lines = items.map { item -> String in + guard let dict = item as? [String: Any] else { + return displayBrowserValue(item) + } + + let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let levelRaw = (dict["level"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let level = levelRaw.isEmpty ? "log" : levelRaw + + if text.isEmpty { + if let message = (dict["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !message.isEmpty { + return "[error] \(message)" + } + return displayBrowserValue(dict) + } + return "[\(level)] \(text)" + } + + return lines.joined(separator: "\n") + } + func nonFlagArgs(_ values: [String]) -> [String] { values.filter { !$0.hasPrefix("-") } } @@ -1929,6 +2967,17 @@ struct CMUXCLI { let (workspaceOpt, argsAfterWorkspace) = parseOption(subArgs, name: "--workspace") let (windowOpt, urlArgs) = parseOption(argsAfterWorkspace, name: "--window") let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + let respectExternalOpenRules: Bool = { + guard let raw = ProcessInfo.processInfo.environment["CMUX_RESPECT_EXTERNAL_OPEN_RULES"] else { + return false + } + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on": + return true + default: + return false + } + }() if surfaceRaw != nil, subcommand == "open" { // Treat `browser open ` as navigate for agent-browser ergonomics. @@ -1954,14 +3003,17 @@ struct CMUXCLI { params["workspace_id"] = workspace } } + if respectExternalOpenRules { + params["respect_external_open_rules"] = true + } if let windowRaw = windowOpt { if let window = try normalizeWindowHandle(windowRaw, client: client) { params["window_id"] = window } } let payload = try client.sendV2(method: "browser.open_split", params: params) - let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown" - let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown" + let surfaceText = formatHandle(payload, kind: "surface", idFormat: effectiveIDFormat) ?? "unknown" + let paneText = formatHandle(payload, kind: "pane", idFormat: effectiveIDFormat) ?? "unknown" let placement = ((payload["created_split"] as? Bool) == true) ? "split" : "reuse" output(payload, fallback: "OK surface=\(surfaceText) pane=\(paneText) placement=\(placement)") return @@ -1969,12 +3021,17 @@ struct CMUXCLI { if subcommand == "goto" || subcommand == "navigate" { let sid = try requireSurface() - let url = subArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + var urlArgs = subArgs + let snapshotAfter = urlArgs.last == "--snapshot-after" + if snapshotAfter { + urlArgs.removeLast() + } + let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard !url.isEmpty else { throw CLIError(message: "browser \(subcommand) requires a URL") } var params: [String: Any] = ["surface_id": sid, "url": url] - if hasFlag(subArgs, name: "--snapshot-after") { + if snapshotAfter { params["snapshot_after"] = true } let payload = try client.sendV2(method: "browser.navigate", params: params) @@ -2001,8 +3058,8 @@ struct CMUXCLI { if subcommand == "url" || subcommand == "get-url" { let sid = try requireSurface() let payload = try client.sendV2(method: "browser.url.get", params: ["surface_id": sid]) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { print((payload["url"] as? String) ?? "") } @@ -2019,8 +3076,8 @@ struct CMUXCLI { if ["is-webview-focused", "is_webview_focused"].contains(subcommand) { let sid = try requireSurface() let payload = try client.sendV2(method: "browser.is_webview_focused", params: ["surface_id": sid]) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { print((payload["focused"] as? Bool) == true ? "true" : "false") } @@ -2053,12 +3110,10 @@ struct CMUXCLI { } let payload = try client.sendV2(method: "browser.snapshot", params: params) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) - } else if let text = payload["snapshot"] as? String { - print(text) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { - print("Empty page") + print(displaySnapshotText(payload)) } return } @@ -2071,7 +3126,13 @@ struct CMUXCLI { throw CLIError(message: "browser eval requires a script") } let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) - output(payload, fallback: "OK") + let fallback: String + if let value = payload["value"] { + fallback = displayBrowserValue(value) + } else { + fallback = "OK" + } + output(payload, fallback: fallback) return } @@ -2260,17 +3321,139 @@ struct CMUXCLI { if subcommand == "screenshot" { let sid = try requireSurface() let (outPathOpt, _) = parseOption(subArgs, name: "--out") - let payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid]) - if let outPathOpt, - let b64 = payload["png_base64"] as? String, - let data = Data(base64Encoded: b64) { - try data.write(to: URL(fileURLWithPath: outPathOpt)) + let localJSONOutput = hasFlag(subArgs, name: "--json") + let outputAsJSON = effectiveJSONOutput || localJSONOutput + var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid]) + + func fileURL(fromPath rawPath: String) -> URL { + let resolvedPath = resolvePath(rawPath) + return URL(fileURLWithPath: resolvedPath).standardizedFileURL } - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + func writeScreenshot(_ data: Data, to destinationURL: URL) throws { + try FileManager.default.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: destinationURL, options: .atomic) + } + + func hasText(_ value: String?) -> Bool { + guard let value else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var screenshotPath = payload["path"] as? String + var screenshotURL = payload["url"] as? String + + func syncScreenshotLocationFields() { + if !hasText(screenshotPath), + let rawURL = screenshotURL, + let fileURL = URL(string: rawURL), + fileURL.isFileURL, + !fileURL.path.isEmpty { + screenshotPath = fileURL.path + } + if !hasText(screenshotURL), + let screenshotPath, + hasText(screenshotPath) { + screenshotURL = URL(fileURLWithPath: screenshotPath).standardizedFileURL.absoluteString + } + if let screenshotPath, hasText(screenshotPath) { + payload["path"] = screenshotPath + } + if let screenshotURL, hasText(screenshotURL) { + payload["url"] = screenshotURL + } + } + + func persistPayloadScreenshot(to destinationURL: URL, allowFailure: Bool) throws -> Bool { + if let sourcePath = screenshotPath, hasText(sourcePath) { + let sourceURL = URL(fileURLWithPath: sourcePath).standardizedFileURL + do { + if sourceURL.path != destinationURL.path { + try FileManager.default.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try? FileManager.default.removeItem(at: destinationURL) + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + } + return true + } catch { + if payload["png_base64"] == nil { + if allowFailure { + return false + } + throw error + } + } + } + + if let b64 = payload["png_base64"] as? String, + let data = Data(base64Encoded: b64) { + do { + try writeScreenshot(data, to: destinationURL) + return true + } catch { + if allowFailure { + return false + } + throw error + } + } + + return false + } + + if let outPathOpt { + let outputURL = fileURL(fromPath: outPathOpt) + guard try persistPayloadScreenshot(to: outputURL, allowFailure: false) else { + throw CLIError(message: "browser screenshot missing image data") + } + screenshotPath = outputURL.path + screenshotURL = outputURL.absoluteString + payload["path"] = screenshotPath + payload["url"] = screenshotURL + } else { + syncScreenshotLocationFields() + if !hasText(screenshotPath) && !hasText(screenshotURL) { + let outputDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-browser-screenshots-cli", isDirectory: true) + if (try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)) != nil { + bestEffortPruneTemporaryFiles(in: outputDir) + let timestampMs = Int(Date().timeIntervalSince1970 * 1000) + let safeSid = sanitizedFilenameComponent(sid) + let filename = "surface-\(safeSid)-\(timestampMs)-\(String(UUID().uuidString.prefix(8))).png" + let outputURL = outputDir.appendingPathComponent(filename, isDirectory: false) + if (try? persistPayloadScreenshot(to: outputURL, allowFailure: true)) == true { + screenshotPath = outputURL.path + screenshotURL = outputURL.absoluteString + payload["path"] = screenshotPath + payload["url"] = screenshotURL + } + } + } + } + + if outputAsJSON { + let formattedPayload = formatIDs(payload, mode: effectiveIDFormat) + if var outputPayload = formattedPayload as? [String: Any] { + if hasText(screenshotPath) || hasText(screenshotURL) { + outputPayload.removeValue(forKey: "png_base64") + } + print(jsonString(outputPayload)) + } else { + print(jsonString(formattedPayload)) + } } else if let outPathOpt { print("OK \(outPathOpt)") + } else if let screenshotURL, + hasText(screenshotURL) { + print("OK \(screenshotURL)") + } else if let screenshotPath, + hasText(screenshotPath) { + print("OK \(screenshotPath)") } else { print("OK") } @@ -2328,8 +3511,8 @@ struct CMUXCLI { "styles": "browser.get.styles", ] let payload = try client.sendV2(method: methodMap[getVerb]!, params: params) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else if let value = payload["value"] { if let str = value as? String { print(str) @@ -2368,8 +3551,8 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser is subcommand: \(isVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid, "selector": selector]) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else if let value = payload["value"] { print("\(value)") } else { @@ -2682,7 +3865,8 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid]) - output(payload, fallback: "OK") + let fallback = displayBrowserLogItems(payload["entries"]) ?? "OK" + output(payload, fallback: fallback) return } @@ -2696,7 +3880,8 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") } let payload = try client.sendV2(method: "browser.errors.list", params: params) - output(payload, fallback: "OK") + let fallback = displayBrowserLogItems(payload["errors"]) ?? "OK" + output(payload, fallback: fallback) return } @@ -3027,10 +4212,111 @@ struct CMUXCLI { throw CLIError(message: "Unable to resolve surface ID") } - /// Return the help/usage text for a subcommand, or nil if the command has no - /// dedicated help (e.g. simple no-arg commands like `ping`). + /// Return the help/usage text for a subcommand, or nil if the command is unknown. private func subcommandUsage(_ command: String) -> String? { switch command { + case "ping": + return """ + Usage: cmux ping + + Check connectivity to the cmux socket server. + """ + case "capabilities": + return """ + Usage: cmux capabilities + + Print server capabilities as JSON. + """ + case "help": + return """ + Usage: cmux help + + Show top-level CLI usage and command list. + """ + case "welcome": + return """ + Usage: cmux welcome + + Show a welcome screen with the cmux logo and useful shortcuts. + Auto-runs once on first launch. + """ + case "shortcuts": + return """ + Usage: cmux shortcuts + + Open the Settings window to Keyboard Shortcuts. + """ + case "feedback": + return """ + Usage: cmux feedback + cmux feedback --email --body [--image ...] + + Without args, open the Send Feedback modal in the running app. + + With args, submit feedback through the app using the same feedback pipeline as the modal. + + Flags: + --email Contact email for follow-up + --body Feedback body + --image Attach an image file, repeat for multiple images + + Coding agents: + Double check with the end user before sending anything. Review the message and attachments for secrets, + private code, credentials, tokens, and other sensitive information first. + """ + case "claude-teams": + return String(localized: "cli.claude-teams.usage", defaultValue: """ + Usage: cmux claude-teams [claude-args...] + + Launch Claude Code with agent teams enabled. + + This command: + - defaults Claude teammate mode to auto + - sets a tmux-like environment so Claude auto mode uses cmux splits + - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + - prepends a private tmux shim to PATH + - forwards all remaining arguments to claude + + The tmux shim translates supported tmux window/pane commands into cmux + workspace and split operations in the current cmux session. + + Examples: + cmux claude-teams + cmux claude-teams --continue + cmux claude-teams --model sonnet + """) + case "identify": + return """ + Usage: cmux identify [--workspace ] [--surface ] [--no-caller] + + Print server identity and caller context details. + + Flags: + --workspace Caller workspace context (default: $CMUX_WORKSPACE_ID) + --surface Caller surface context (default: $CMUX_SURFACE_ID) + --no-caller Omit caller context from the request + """ + case "list-windows": + return """ + Usage: cmux list-windows + + List open windows. + """ + case "current-window": + return """ + Usage: cmux current-window + + Print the currently selected window ID. + """ + case "new-window": + return """ + Usage: cmux new-window + + Create a new window. + + Example: + cmux new-window + """ case "focus-window": return """ Usage: cmux focus-window --window @@ -3059,47 +4345,56 @@ struct CMUXCLI { """ case "move-workspace-to-window": return """ - Usage: cmux move-workspace-to-window --workspace --window + Usage: cmux move-workspace-to-window --workspace --window Move a workspace to a different window. Flags: - --workspace Workspace to move (required) - --window Target window (required) + --workspace Workspace to move (required) + --window Target window (required) Example: cmux move-workspace-to-window --workspace workspace:2 --window window:1 """ case "move-surface": return """ - Usage: cmux move-surface --surface [flags] + Usage: cmux move-surface [--surface | ] [flags] Move a surface to a different pane, workspace, or window. Flags: - --surface Surface to move (required) + --surface Surface to move (required unless passed positionally) --pane Target pane --workspace Target workspace --window Target window --before Place before this surface + --before-surface + Alias for --before --after Place after this surface + --after-surface + Alias for --after --index Place at this index --focus Focus the surface after moving Example: cmux move-surface --surface surface:1 --workspace workspace:2 - cmux move-surface --surface 0 --pane pane:2 --index 0 + cmux move-surface surface:1 --pane pane:2 --index 0 """ case "reorder-surface": return """ - Usage: cmux reorder-surface --surface [flags] + Usage: cmux reorder-surface [--surface | ] [flags] Reorder a surface within its pane. Flags: - --surface Surface to reorder (required) + --surface Surface to reorder (required unless passed positionally) + --workspace Workspace context --before Place before this surface + --before-surface + Alias for --before --after Place after this surface + --after-surface + Alias for --after --index Place at this index Example: @@ -3108,15 +4403,19 @@ struct CMUXCLI { """ case "reorder-workspace": return """ - Usage: cmux reorder-workspace --workspace [flags] + Usage: cmux reorder-workspace [--workspace | ] [flags] Reorder a workspace within its window. Flags: - --workspace Workspace to reorder (required) + --workspace Workspace to reorder (required unless passed positionally) --index Place at this index --before Place before this workspace + --before-workspace + Alias for --before --after Place after this workspace + --after-workspace + Alias for --after --window Window context Example: @@ -3139,7 +4438,7 @@ struct CMUXCLI { Flags: --action Action name (required if not positional) --workspace Target workspace (default: current/$CMUX_WORKSPACE_ID) - --title Title for rename + --title Title for rename (or pass trailing title text) Example: cmux workspace-action --workspace workspace:2 --action pin @@ -3162,10 +4461,10 @@ struct CMUXCLI { Flags: --action Action name (required if not positional) - --tab Target tab (accepts tab: or surface:; alias: --surface) + --tab Target tab (accepts tab: or surface:; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab) --surface Alias for --tab (backward compatibility) --workspace Workspace context (default: current/$CMUX_WORKSPACE_ID) - --title Title for rename + --title Title for rename (or pass trailing title text) --url Optional URL for new-browser-right Example: @@ -3195,12 +4494,27 @@ struct CMUXCLI { """ case "new-workspace": return """ - Usage: cmux new-workspace + Usage: cmux new-workspace [--cwd ] [--command ] Create a new workspace in the current window. + Flags: + --cwd Set the working directory for the new workspace + --command Send text+Enter to the new workspace after creation + Example: cmux new-workspace + cmux new-workspace --cwd ~/projects/myapp + cmux new-workspace --cwd . --command "npm test" + """ + case "list-workspaces": + return """ + Usage: cmux list-workspaces + + List workspaces in the current window. + + Example: + cmux list-workspaces """ case "new-split": return """ @@ -3217,18 +4531,72 @@ struct CMUXCLI { cmux new-split right cmux new-split down --workspace workspace:1 """ + case "list-panes": + return """ + Usage: cmux list-panes [--workspace ] + + List panes in a workspace. + + Flags: + --workspace Workspace context (default: $CMUX_WORKSPACE_ID) + + Example: + cmux list-panes + cmux list-panes --workspace workspace:2 + """ + case "list-pane-surfaces": + return """ + Usage: cmux list-pane-surfaces [--workspace ] [--pane ] + + List surfaces in a pane. + + Flags: + --workspace Workspace context (default: $CMUX_WORKSPACE_ID) + --pane Restrict to a specific pane (default: focused pane) + + Example: + cmux list-pane-surfaces + cmux list-pane-surfaces --workspace workspace:2 --pane pane:1 + """ + case "tree": + return """ + Usage: cmux tree [flags] + + Print the hierarchy of windows, workspaces, panes, and surfaces. + + Flags: + --all Include all windows (default: current window only) + --workspace Show only one workspace + --json Structured JSON output + + Output: + Text mode prints a box-drawing tree with markers: + - ◀ active (true focused window/workspace/pane/surface path) + - ◀ here (caller surface where `cmux tree` was invoked) + - workspace [selected] + - pane [focused] + - surface [selected] + Browser surfaces also include their current URL. + + Example: + cmux tree + cmux tree --all + cmux tree --workspace workspace:2 + cmux --json tree --all + """ case "focus-pane": return """ - Usage: cmux focus-pane --pane [flags] + Usage: cmux focus-pane [--pane | ] [flags] Focus the specified pane. Flags: - --pane Pane to focus (required) + --pane Pane to focus (required unless passed positionally) --workspace Workspace context (default: $CMUX_WORKSPACE_ID) Example: cmux focus-pane --pane pane:2 + cmux focus-pane pane:1 cmux focus-pane --pane pane:1 --workspace workspace:2 """ case "new-pane": @@ -3292,26 +4660,87 @@ struct CMUXCLI { cmux drag-surface-to-split --surface surface:1 right cmux drag-surface-to-split --panel surface:2 down """ + case "refresh-surfaces": + return """ + Usage: cmux refresh-surfaces + + Refresh surface snapshots for the focused workspace. + """ + case "surface-health": + return """ + Usage: cmux surface-health [--workspace ] + + List health details for surfaces in a workspace. + + Flags: + --workspace Workspace context (default: $CMUX_WORKSPACE_ID) + + Example: + cmux surface-health + cmux surface-health --workspace workspace:2 + """ + case "trigger-flash": + return """ + Usage: cmux trigger-flash [--workspace ] [--surface ] [--panel ] + + Trigger the unread flash indicator for a surface. + + Flags: + --workspace Workspace context (default: $CMUX_WORKSPACE_ID) + --surface Target surface (default: $CMUX_SURFACE_ID) + --panel Alias for --surface + + Example: + cmux trigger-flash + cmux trigger-flash --workspace workspace:2 --surface surface:3 + """ + case "list-panels": + return """ + Usage: cmux list-panels [--workspace ] + + List surfaces (panels) in a workspace. + + Flags: + --workspace Workspace context (default: $CMUX_WORKSPACE_ID) + + Example: + cmux list-panels + cmux list-panels --workspace workspace:2 + """ + case "focus-panel": + return """ + Usage: cmux focus-panel --panel [--workspace ] + + Focus a specific panel (surface). + + Flags: + --panel Panel/surface to focus (required) + --workspace Workspace context (default: $CMUX_WORKSPACE_ID) + + Example: + cmux focus-panel --panel surface:2 + cmux focus-panel --panel surface:5 --workspace workspace:2 + """ case "close-workspace": return """ - Usage: cmux close-workspace --workspace + Usage: cmux close-workspace --workspace Close the specified workspace. Flags: - --workspace Workspace to close (required) + --workspace Workspace to close (required) Example: cmux close-workspace --workspace workspace:2 """ case "select-workspace": return """ - Usage: cmux select-workspace --workspace + Usage: cmux select-workspace --workspace Select (switch to) the specified workspace. Flags: - --workspace Workspace to select (required) + --workspace Workspace to select (required) Example: cmux select-workspace --workspace workspace:2 @@ -3319,51 +4748,210 @@ struct CMUXCLI { """ case "rename-workspace", "rename-window": return """ - Usage: cmux rename-workspace [--workspace ] [--] + Usage: cmux rename-workspace [--workspace <id|ref|index>] [--] <title> Rename a workspace. Defaults to the current workspace. tmux-compatible alias: rename-window Flags: - --workspace <id|ref> Workspace to rename (default: current workspace) + --workspace <id|ref|index> Workspace to rename (default: current/$CMUX_WORKSPACE_ID) Example: cmux rename-workspace "backend logs" cmux rename-window --workspace workspace:2 "agent run" """ + case "current-workspace": + return """ + Usage: cmux current-workspace + + Print the currently selected workspace ID. + """ case "capture-pane": return """ Usage: cmux capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] tmux-compatible alias for reading terminal text from a pane. + Flags: + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + --surface <id|ref> Surface context (default: $CMUX_SURFACE_ID) + --scrollback Include scrollback + --lines <n> Return only the last N lines (implies --scrollback) + Example: cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200 """ case "resize-pane": return """ - Usage: cmux resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>] + Usage: cmux resize-pane [--pane <id|ref>] [--workspace <id|ref>] [-L|-R|-U|-D] [--amount <n>] tmux-compatible pane resize command. - Note: currently returns not_supported until programmable divider resize is implemented. + + Flags: + --pane <id|ref> Pane to resize (default: focused pane) + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + -L|-R|-U|-D Direction (default: -R) + --amount <n> Resize amount (default: 1) """ case "pipe-pane": return """ - Usage: cmux pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>] + Usage: cmux pipe-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <shell-command> | <shell-command>] Capture pane text and pipe it to a shell command via stdin. + + Flags: + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + --surface <id|ref> Surface context (default: focused surface) + --command <command> Shell command to run (or pass as trailing text) """ case "wait-for": return """ Usage: cmux wait-for [-S|--signal] <name> [--timeout <seconds>] Wait for or signal a named synchronization token. - """ - case "swap-pane", "break-pane", "join-pane", "next-window", "previous-window", "last-window", "last-pane", "find-window", "clear-history", "set-hook", "popup", "bind-key", "unbind-key", "copy-mode", "set-buffer", "paste-buffer", "list-buffers", "respawn-pane", "display-message": - return """ - Usage: cmux \(command) --help - tmux compatibility command. See `cmux --help` for exact syntax. + Flags: + -S, --signal Signal the token instead of waiting + --timeout <seconds> Wait timeout (default: 30) + """ + case "swap-pane": + return """ + Usage: cmux swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] + + Swap two panes. + + Flags: + --pane <id|ref> Source pane (required) + --target-pane <id|ref> Target pane (required) + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + """ + case "break-pane": + return """ + Usage: cmux break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + + Move a pane/surface out into its own pane context. + + Flags: + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + --pane <id|ref> Source pane + --surface <id|ref> Source surface + --no-focus Do not focus the result + """ + case "join-pane": + return """ + Usage: cmux join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + + Join a pane/surface into another pane. + + Flags: + --target-pane <id|ref> Target pane (required) + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + --pane <id|ref> Source pane + --surface <id|ref> Source surface + --no-focus Do not focus the result + """ + case "next-window", "previous-window", "last-window": + return """ + Usage: cmux \(command) + + Switch workspace selection (next/previous/last) in the current window. + """ + case "last-pane": + return """ + Usage: cmux last-pane [--workspace <id|ref>] + + Focus the previously focused pane in a workspace. + + Flags: + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + """ + case "find-window": + return """ + Usage: cmux find-window [--content] [--select] [query] + + Find workspaces by title (and optionally terminal content). + + Flags: + --content Search terminal content in addition to workspace titles + --select Select the first match + """ + case "clear-history": + return """ + Usage: cmux clear-history [--workspace <id|ref>] [--surface <id|ref>] + + Clear terminal scrollback history. + + Flags: + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + --surface <id|ref> Surface context (default: focused surface) + """ + case "set-hook": + return """ + Usage: cmux set-hook [--list] [--unset <event>] | <event> <command> + + Manage tmux-compat hook definitions. + + Flags: + --list List configured hooks + --unset <event> Remove a hook by event name + """ + case "popup": + return """ + Usage: cmux popup + + tmux compatibility placeholder. This command is currently not supported. + """ + case "bind-key", "unbind-key", "copy-mode": + return """ + Usage: cmux \(command) + + tmux compatibility placeholder. This command is currently not supported. + """ + case "set-buffer": + return """ + Usage: cmux set-buffer [--name <name>] [--] <text> + + Save text into a named tmux-compat buffer. + + Flags: + --name <name> Buffer name (default: default) + """ + case "paste-buffer": + return """ + Usage: cmux paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>] + + Paste a named tmux-compat buffer into a surface. + + Flags: + --name <name> Buffer name (default: default) + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + --surface <id|ref> Surface context (default: focused surface) + """ + case "list-buffers": + return """ + Usage: cmux list-buffers + + List tmux-compat buffers. + """ + case "respawn-pane": + return """ + Usage: cmux respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd> | <cmd>] + + Send a command (or default shell restart command) to a surface. + + Flags: + --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) + --surface <id|ref> Surface context (default: focused surface) + --command <cmd> Command text (or pass trailing command text) + """ + case "display-message": + return """ + Usage: cmux display-message [-p|--print] <text> + + Print text (or show it via notification bridge in parity mode). + + Flags: + -p, --print Print to stdout only """ case "read-screen": return """ @@ -3453,16 +5041,172 @@ struct CMUXCLI { cmux notify --title "Build done" --body "All tests passed" cmux notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error" """ + case "list-notifications": + return """ + Usage: cmux list-notifications + + List queued notifications. + """ + case "clear-notifications": + return """ + Usage: cmux clear-notifications + + Clear all queued notifications. + """ + case "set-status": + return """ + Usage: cmux set-status <key> <value> [flags] + + Set a sidebar status entry for a workspace. Status entries appear as + pills in the sidebar tab row. Use a unique key so different tools + (e.g. "claude_code", "build") can manage their own entries. + + Flags: + --icon <name> Icon name (e.g. "sparkle", "hammer") + --color <#hex> Pill color (e.g. "#ff9500") + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + + Example: + cmux set-status build "compiling" --icon hammer --color "#ff9500" + cmux set-status deploy "v1.2.3" --workspace workspace:2 + """ + case "clear-status": + return """ + Usage: cmux clear-status <key> [flags] + + Remove a sidebar status entry by key. + + Flags: + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + + Example: + cmux clear-status build + """ + case "list-status": + return """ + Usage: cmux list-status [flags] + + List all sidebar status entries for a workspace. + + Flags: + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + + Example: + cmux list-status + cmux list-status --workspace workspace:2 + """ + case "set-progress": + return """ + Usage: cmux set-progress <0.0-1.0> [flags] + + Set a progress bar in the sidebar for a workspace. + + Flags: + --label <text> Label shown next to the progress bar + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + + Example: + cmux set-progress 0.5 --label "Building..." + cmux set-progress 1.0 --label "Done" + """ + case "clear-progress": + return """ + Usage: cmux clear-progress [flags] + + Clear the sidebar progress bar for a workspace. + + Flags: + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + + Example: + cmux clear-progress + """ + case "log": + return """ + Usage: cmux log [flags] [--] <message> + + Append a log entry to the sidebar for a workspace. + + Flags: + --level <level> Log level: info, progress, success, warning, error (default: info) + --source <name> Source label (e.g. "build", "test") + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + + Example: + cmux log "Build started" + cmux log --level error --source build "Compilation failed" + cmux log --level success -- "All 42 tests passed" + """ + case "clear-log": + return """ + Usage: cmux clear-log [flags] + + Clear all sidebar log entries for a workspace. + + Flags: + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + + Example: + cmux clear-log + """ + case "list-log": + return """ + Usage: cmux list-log [flags] + + List sidebar log entries for a workspace. + + Flags: + --limit <n> Show only the last N entries + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + + Example: + cmux list-log + cmux list-log --limit 5 + """ + case "sidebar-state": + return """ + Usage: cmux sidebar-state [flags] + + Dump all sidebar metadata for a workspace (cwd, git branch, ports, + status entries, progress, log entries). + + Flags: + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + + Example: + cmux sidebar-state + cmux sidebar-state --workspace workspace:2 + """ + case "set-app-focus": + return """ + Usage: cmux set-app-focus <active|inactive|clear> + + Override app focus state for notification routing tests. + + Example: + cmux set-app-focus inactive + cmux set-app-focus clear + """ + case "simulate-app-active": + return """ + Usage: cmux simulate-app-active + + Trigger the app-active handler used by notification focus tests. + """ case "claude-hook": return """ - Usage: cmux claude-hook <session-start|stop|notification> [flags] + Usage: cmux claude-hook <session-start|active|stop|idle|notification|notify|prompt-submit> [flags] Hook for Claude Code integration. Reads JSON from stdin. Subcommands: session-start Signal that a Claude session has started + active Alias for session-start stop Signal that a Claude session has stopped + idle Alias for stop notification Forward a Claude notification + notify Alias for notification + prompt-submit Clear notification and set Running on user prompt Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) @@ -3477,29 +5221,100 @@ struct CMUXCLI { Usage: cmux browser [--surface <id|ref|index> | <surface>] <subcommand> [args] Browser automation commands. Most subcommands require a surface handle. + A surface can be passed as `--surface <handle>` or as the first positional token. + `open`/`open-split`/`new`/`identify` can run without an explicit surface. Subcommands: - open [url] Create browser split (or navigate if surface given) - open-split [url] Create browser in a new split - goto|navigate <url> Navigate to URL [--snapshot-after] - back|forward|reload History navigation [--snapshot-after] - url|get-url Get current URL - snapshot Get DOM snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth <n>] [--selector <css>] - eval <script> Evaluate JavaScript - wait Wait for condition [--selector] [--text] [--url-contains] [--timeout-ms] - click|dblclick|hover <sel> Mouse actions [--snapshot-after] - type <selector> <text> Type text [--snapshot-after] - fill <selector> [text] Fill input [--snapshot-after] - press|keydown|keyup <key> Keyboard actions [--snapshot-after] - get <property> [selector] Get page properties (url|title|text|html|value|attr|count|box|styles) - find <strategy> <query> Find elements (role|text|label|placeholder|testid|first|last|nth) - identify Identify browser surface + open|open-split|new [url] [--workspace <id|ref|index>] [--window <id|ref|index>] + open/open-split/new default to $CMUX_WORKSPACE_ID when --workspace is omitted and --window is not set + goto|navigate <url> [--snapshot-after] + back|forward|reload [--snapshot-after] + url|get-url + focus-webview | is-webview-focused + snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth <n>] [--selector <css>] + eval [--script <js> | <js>] + wait [--selector <css>] [--text <text>] [--url-contains <text>|--url <text>] [--load-state <interactive|complete>] [--function <js>] [--timeout-ms <ms>|--timeout <seconds>] + click|dblclick|hover|focus|check|uncheck|scroll-into-view [--selector <css> | <css>] [--snapshot-after] + type|fill [--selector <css> | <css>] [--text <text> | <text>] [--snapshot-after] + press|key|keydown|keyup [--key <key> | <key>] [--snapshot-after] + select [--selector <css> | <css>] [--value <value> | <value>] [--snapshot-after] + scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after] + screenshot [--out <path>] + get <url|title|text|html|value|attr|count|box|styles> [...] + text|html|value|count|box|styles|attr: [--selector <css> | <css>] + attr: [--attr <name> | <name>] + styles: [--property <name>] + is <visible|enabled|checked> [--selector <css> | <css>] + find <role|text|label|placeholder|alt|title|testid|first|last|nth> [...] + role: [--name <text>] [--exact] <role> + text|label|placeholder|alt|title|testid: [--exact] <text> + first|last: [--selector <css> | <css>] + nth: [--index <n> | <n>] [--selector <css> | <css>] + frame <main|selector> [--selector <css>] + dialog <accept|dismiss> [text] + download [wait] [--path <path>] [--timeout-ms <ms>|--timeout <seconds>] + cookies <get|set|clear> [--name <name>] [--value <value>] [--url <url>] [--domain <domain>] [--path <path>] [--expires <unix>] [--secure] [--all] + storage <local|session> <get|set|clear> [...] + tab <new|list|switch|close|<index>> [...] + console <list|clear> + errors <list|clear> + highlight [--selector <css> | <css>] + state <save|load> <path> + addinitscript|addscript [--script <js> | <js>] + addstyle [--css <css> | <css>] + viewport <width> <height> + geolocation|geo <latitude> <longitude> + offline <true|false> + trace <start|stop> [path] + network <route|unroute|requests> ... + route <pattern> [--abort] [--body <text>] + unroute <pattern> + screencast <start|stop> + input <mouse|keyboard|touch> [args...] + input_mouse | input_keyboard | input_touch + identify [--surface <id|ref|index>] Example: cmux browser open https://example.com cmux browser surface:1 navigate https://google.com cmux browser --surface surface:1 snapshot --interactive """ + // Legacy browser aliases — point users to `cmux browser --help` + case "open-browser": + return "Legacy alias for 'cmux browser open'. Run 'cmux browser --help' for details." + case "navigate": + return "Legacy alias for 'cmux browser navigate'. Run 'cmux browser --help' for details." + case "browser-back": + return "Legacy alias for 'cmux browser back'. Run 'cmux browser --help' for details." + case "browser-forward": + return "Legacy alias for 'cmux browser forward'. Run 'cmux browser --help' for details." + case "browser-reload": + return "Legacy alias for 'cmux browser reload'. Run 'cmux browser --help' for details." + case "get-url": + return "Legacy alias for 'cmux browser get-url'. Run 'cmux browser --help' for details." + case "focus-webview": + return "Legacy alias for 'cmux browser focus-webview'. Run 'cmux browser --help' for details." + case "is-webview-focused": + return "Legacy alias for 'cmux browser is-webview-focused'. Run 'cmux browser --help' for details." + case "markdown": + return """ + Usage: cmux markdown open <path> [options] + cmux markdown <path> (shorthand for 'open') + + Open a markdown file in a formatted viewer panel with live file watching. + The file is rendered with rich formatting (headings, code blocks, tables, + lists, blockquotes) and automatically updates when the file changes on disk. + + Options: + --workspace <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID) + --surface <id|ref|index> Source surface to split from (default: focused surface) + --window <id|ref|index> Target window + + Examples: + cmux markdown open plan.md + cmux markdown ~/project/CHANGELOG.md + cmux markdown open ./docs/design.md --workspace 0 + """ default: return nil } @@ -3515,6 +5330,20 @@ struct CMUXCLI { return true } + /// Escape and quote a string for safe embedding in a v1 socket command. + /// The socket tokenizer treats `\` and `"` as special inside quoted strings, + /// so both must be escaped before wrapping in double quotes. Newlines and + /// carriage returns must also be escaped since the socket protocol uses + /// newline as the message terminator. + private func socketQuote(_ s: String) -> String { + let escaped = s + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + return "\"\(escaped)\"" + } + private func parseOption(_ args: [String], name: String) -> (String?, [String]) { var remaining: [String] = [] var value: String? @@ -3540,6 +5369,31 @@ struct CMUXCLI { return (value, remaining) } + private func parseRepeatedOption(_ args: [String], name: String) -> ([String], [String]) { + var remaining: [String] = [] + var values: [String] = [] + var skipNext = false + var pastTerminator = false + for (idx, arg) in args.enumerated() { + if skipNext { + skipNext = false + continue + } + if arg == "--" { + pastTerminator = true + remaining.append(arg) + continue + } + if !pastTerminator, arg == name, idx + 1 < args.count { + values.append(args[idx + 1]) + skipNext = true + continue + } + remaining.append(arg) + } + return (values, remaining) + } + private func optionValue(_ args: [String], name: String) -> String? { guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil } return args[index + 1] @@ -3590,19 +5444,1677 @@ struct CMUXCLI { return parts.joined(separator: " ") } + private struct TreeCommandOptions { + let includeAllWindows: Bool + let workspaceHandle: String? + let jsonOutput: Bool + } + + private struct TreePath { + let windowHandle: String? + let workspaceHandle: String? + let paneHandle: String? + let surfaceHandle: String? + } + + private func runTreeCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat + ) throws { + let options = try parseTreeCommandOptions(commandArgs) + let payload = try buildTreePayload(options: options, client: client) + if jsonOutput || options.jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + let windows = payload["windows"] as? [[String: Any]] ?? [] + print(renderTreeText(windows: windows, idFormat: idFormat)) + } + } + + private func parseTreeCommandOptions(_ args: [String]) throws -> TreeCommandOptions { + let (workspaceOpt, rem0) = parseOption(args, name: "--workspace") + if rem0.contains("--workspace") { + throw CLIError(message: "tree requires --workspace <id|ref|index>") + } + + var includeAll = false + var jsonOutput = false + var remaining: [String] = [] + for arg in rem0 { + if arg == "--all" { + includeAll = true + continue + } + if arg == "--json" { + jsonOutput = true + continue + } + remaining.append(arg) + } + + if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "tree: unknown flag '\(unknown)'. Known flags: --all --workspace <id|ref|index> --json") + } + if let extra = remaining.first { + throw CLIError(message: "tree: unexpected argument '\(extra)'") + } + + return TreeCommandOptions(includeAllWindows: includeAll, workspaceHandle: workspaceOpt, jsonOutput: jsonOutput) + } + + private func buildTreePayload( + options: TreeCommandOptions, + client: SocketClient + ) throws -> [String: Any] { + var params: [String: Any] = ["all_windows": options.includeAllWindows] + if let workspaceRaw = options.workspaceHandle { + guard let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) else { + throw CLIError(message: "Invalid workspace handle") + } + params["workspace_id"] = workspaceHandle + } + if let caller = treeCallerContextFromEnvironment() { + params["caller"] = caller + } + + do { + let payload = try client.sendV2(method: "system.tree", params: params) + return treePayloadWithMarkers(payload) + } catch let error as CLIError where error.message.hasPrefix("method_not_found:") { + // Back-compat fallback for older servers that don't support system.tree. + return try buildLegacyTreePayload(options: options, params: params, client: client) + } + } + + private func buildLegacyTreePayload( + options: TreeCommandOptions, + params: [String: Any], + client: SocketClient + ) throws -> [String: Any] { + var identifyParams: [String: Any] = [:] + if let caller = params["caller"] as? [String: Any], !caller.isEmpty { + identifyParams["caller"] = caller + } + + let identifyPayload = try client.sendV2(method: "system.identify", params: identifyParams) + let focused = identifyPayload["focused"] as? [String: Any] ?? [:] + let caller = identifyPayload["caller"] as? [String: Any] ?? [:] + let activePath = parseTreePath(payload: focused) + let windows = try buildTreeWindowNodes(options: options, activePath: activePath, client: client) + + return treePayloadWithMarkers([ + "active": focused.isEmpty ? NSNull() : focused, + "caller": caller.isEmpty ? NSNull() : caller, + "windows": windows + ]) + } + + private func buildTreeWindowNodes( + options: TreeCommandOptions, + activePath: TreePath, + client: SocketClient + ) throws -> [[String: Any]] { + let windowsPayload = try client.sendV2(method: "window.list") + let allWindows = windowsPayload["windows"] as? [[String: Any]] ?? [] + + if let workspaceRaw = options.workspaceHandle { + guard let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) else { + throw CLIError(message: "Invalid workspace handle") + } + + let workspaceListPayload = try client.sendV2(method: "workspace.list", params: ["workspace_id": workspaceHandle]) + let workspaceWindowHandle = (workspaceListPayload["window_ref"] as? String) ?? (workspaceListPayload["window_id"] as? String) + let window = allWindows.first(where: { treeItemMatchesHandle($0, handle: workspaceWindowHandle) }) + ?? treeFallbackWindow(from: workspaceListPayload) + + let workspaces = workspaceListPayload["workspaces"] as? [[String: Any]] ?? [] + if workspaces.isEmpty { + throw CLIError(message: "Workspace not found") + } + let workspaceNodes = try workspaces.map { try buildTreeWorkspaceNode(workspace: $0, activePath: activePath, client: client) } + var node = window + let isActiveWindow = treeItemMatchesHandle(node, handle: activePath.windowHandle) + node["current"] = isActiveWindow + node["active"] = isActiveWindow + node["workspaces"] = workspaceNodes + node["workspace_count"] = workspaceNodes.count + return [node] + } + + let targetWindows: [[String: Any]] + if options.includeAllWindows { + targetWindows = allWindows + } else if let currentWindowHandle = activePath.windowHandle { + let currentOnly = allWindows.filter { treeItemMatchesHandle($0, handle: currentWindowHandle) } + targetWindows = currentOnly.isEmpty ? Array(allWindows.prefix(1)) : currentOnly + } else { + targetWindows = Array(allWindows.prefix(1)) + } + + return try targetWindows.map { + try buildTreeWindowNode( + window: $0, + activePath: activePath, + client: client + ) + } + } + + private func treeFallbackWindow(from payload: [String: Any]) -> [String: Any] { + let workspaces = payload["workspaces"] as? [[String: Any]] ?? [] + let selectedWorkspace = workspaces.first(where: { ($0["selected"] as? Bool) == true }) + return [ + "id": payload["window_id"] ?? NSNull(), + "ref": payload["window_ref"] ?? NSNull(), + "index": 0, + "key": false, + "visible": true, + "workspace_count": workspaces.count, + "selected_workspace_id": selectedWorkspace?["id"] ?? NSNull(), + "selected_workspace_ref": selectedWorkspace?["ref"] ?? NSNull(), + ] + } + + private func buildTreeWindowNode( + window: [String: Any], + activePath: TreePath, + client: SocketClient + ) throws -> [String: Any] { + var workspaceParams: [String: Any] = [:] + if let windowHandle = treeItemHandle(window) { + workspaceParams["window_id"] = windowHandle + } + let workspacePayload = try client.sendV2(method: "workspace.list", params: workspaceParams) + let workspaces = workspacePayload["workspaces"] as? [[String: Any]] ?? [] + let workspaceNodes = try workspaces.map { try buildTreeWorkspaceNode(workspace: $0, activePath: activePath, client: client) } + var windowNode = window + let isActiveWindow = treeItemMatchesHandle(windowNode, handle: activePath.windowHandle) + windowNode["current"] = isActiveWindow + windowNode["active"] = isActiveWindow + windowNode["workspaces"] = workspaceNodes + windowNode["workspace_count"] = workspaceNodes.count + return windowNode + } + + private func buildTreeWorkspaceNode( + workspace: [String: Any], + activePath: TreePath, + client: SocketClient + ) throws -> [String: Any] { + var workspaceNode = workspace + guard let workspaceHandle = treeItemHandle(workspace) else { + workspaceNode["panes"] = [] + return workspaceNode + } + + let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceHandle]) + let surfacePayload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceHandle]) + let panes = panePayload["panes"] as? [[String: Any]] ?? [] + let surfaces = surfacePayload["surfaces"] as? [[String: Any]] ?? [] + let browserURLsByHandle = fetchTreeBrowserURLs( + workspaceHandle: workspaceHandle, + surfaces: surfaces, + client: client + ) + + var surfacesByPane: [String: [[String: Any]]] = [:] + for surface in surfaces { + var surfaceNode = surface + if surfaceNode["selected"] == nil { + surfaceNode["selected"] = (surfaceNode["selected_in_pane"] as? Bool) == true + } + surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle) + + let surfaceType = ((surfaceNode["type"] as? String) ?? "").lowercased() + if surfaceType == "browser", + let url = treeBrowserURL(surface: surfaceNode, urlsByHandle: browserURLsByHandle), + !url.isEmpty { + surfaceNode["url"] = url + } else { + surfaceNode["url"] = NSNull() + } + + guard let paneHandle = treeRelatedHandle(surfaceNode, refKey: "pane_ref", idKey: "pane_id") else { + continue + } + surfacesByPane[paneHandle, default: []].append(surfaceNode) + } + + for paneHandle in surfacesByPane.keys { + surfacesByPane[paneHandle]?.sort { + let lhs = intFromAny($0["index_in_pane"]) ?? intFromAny($0["index"]) ?? Int.max + let rhs = intFromAny($1["index_in_pane"]) ?? intFromAny($1["index"]) ?? Int.max + return lhs < rhs + } + } + + let paneNodes: [[String: Any]] = panes.map { pane in + var paneNode = pane + paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle) + if let paneHandle = treeItemHandle(paneNode) { + paneNode["surfaces"] = surfacesByPane[paneHandle] ?? [] + } else { + paneNode["surfaces"] = [] + } + return paneNode + } + + workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle) + workspaceNode["panes"] = paneNodes + return workspaceNode + } + + private func treeItemHandle(_ item: [String: Any]) -> String? { + if let ref = item["ref"] as? String, !ref.isEmpty { + return ref + } + if let id = item["id"] as? String, !id.isEmpty { + return id + } + return nil + } + + private func treeRelatedHandle(_ item: [String: Any], refKey: String, idKey: String) -> String? { + if let ref = item[refKey] as? String, !ref.isEmpty { + return ref + } + if let id = item[idKey] as? String, !id.isEmpty { + return id + } + return nil + } + + private func parseTreePath(payload: [String: Any]) -> TreePath { + return TreePath( + windowHandle: treeRelatedHandle(payload, refKey: "window_ref", idKey: "window_id"), + workspaceHandle: treeRelatedHandle(payload, refKey: "workspace_ref", idKey: "workspace_id"), + paneHandle: treeRelatedHandle(payload, refKey: "pane_ref", idKey: "pane_id"), + surfaceHandle: treeRelatedHandle(payload, refKey: "surface_ref", idKey: "surface_id") + ) + } + + private func treeCallerContextFromEnvironment() -> [String: Any]? { + let env = ProcessInfo.processInfo.environment + let workspaceRaw = env["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let surfaceRaw = env["CMUX_SURFACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) + var caller: [String: Any] = [:] + if let workspaceRaw, !workspaceRaw.isEmpty { + caller["workspace_id"] = workspaceRaw + } + if let surfaceRaw, !surfaceRaw.isEmpty { + caller["surface_id"] = surfaceRaw + } + return caller.isEmpty ? nil : caller + } + + private func treePayloadWithMarkers(_ payload: [String: Any]) -> [String: Any] { + let active = payload["active"] as? [String: Any] ?? [:] + let caller = payload["caller"] as? [String: Any] ?? [:] + let activePath = parseTreePath(payload: active) + let callerPath = parseTreePath(payload: caller) + var result = payload + let windows = payload["windows"] as? [[String: Any]] ?? [] + result["windows"] = treeApplyMarkers(windows: windows, activePath: activePath, callerPath: callerPath) + if result["active"] == nil { + result["active"] = active.isEmpty ? NSNull() : active + } + if result["caller"] == nil { + result["caller"] = caller.isEmpty ? NSNull() : caller + } + return result + } + + private func treeApplyMarkers( + windows: [[String: Any]], + activePath: TreePath, + callerPath: TreePath + ) -> [[String: Any]] { + return windows.map { window in + var windowNode = window + let isActiveWindow = treeItemMatchesHandle(windowNode, handle: activePath.windowHandle) + windowNode["current"] = isActiveWindow + windowNode["active"] = isActiveWindow + + let workspaces = window["workspaces"] as? [[String: Any]] ?? [] + let workspaceNodes = workspaces.map { workspace in + var workspaceNode = workspace + workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle) + + let panes = workspace["panes"] as? [[String: Any]] ?? [] + let paneNodes = panes.map { pane in + var paneNode = pane + paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle) + + let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] + paneNode["surfaces"] = surfaces.map { surface in + var surfaceNode = surface + surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle) + surfaceNode["here"] = treeItemMatchesHandle(surfaceNode, handle: callerPath.surfaceHandle) + return surfaceNode + } + return paneNode + } + + workspaceNode["panes"] = paneNodes + return workspaceNode + } + + windowNode["workspaces"] = workspaceNodes + return windowNode + } + } + + private func fetchTreeBrowserURLs( + workspaceHandle: String, + surfaces: [[String: Any]], + client: SocketClient + ) -> [String: String] { + let hasBrowserSurfaces = surfaces.contains { + (($0["type"] as? String) ?? "").lowercased() == "browser" + } + guard hasBrowserSurfaces else { return [:] } + + if let payload = try? client.sendV2( + method: "browser.tab.list", + params: ["workspace_id": workspaceHandle] + ) { + let tabs = payload["tabs"] as? [[String: Any]] ?? [] + var urlByHandle: [String: String] = [:] + for tab in tabs { + guard let url = tab["url"] as? String, !url.isEmpty else { continue } + if let id = tab["id"] as? String, !id.isEmpty { + urlByHandle[id] = url + } + if let ref = tab["ref"] as? String, !ref.isEmpty { + urlByHandle[ref] = url + } + } + return urlByHandle + } + + // Fallback for older servers that may not support browser.tab.list. + var fallbackURLs: [String: String] = [:] + for surface in surfaces { + guard ((surface["type"] as? String) ?? "").lowercased() == "browser" else { continue } + guard let surfaceHandle = treeItemHandle(surface) else { continue } + guard let payload = try? client.sendV2( + method: "browser.url.get", + params: ["workspace_id": workspaceHandle, "surface_id": surfaceHandle] + ), + let url = payload["url"] as? String, + !url.isEmpty else { + continue + } + fallbackURLs[surfaceHandle] = url + if let id = surface["id"] as? String, !id.isEmpty { + fallbackURLs[id] = url + } + if let ref = surface["ref"] as? String, !ref.isEmpty { + fallbackURLs[ref] = url + } + } + return fallbackURLs + } + + private func treeBrowserURL(surface: [String: Any], urlsByHandle: [String: String]) -> String? { + if let id = surface["id"] as? String, let url = urlsByHandle[id] { + return url + } + if let ref = surface["ref"] as? String, let url = urlsByHandle[ref] { + return url + } + if let handle = treeItemHandle(surface), let url = urlsByHandle[handle] { + return url + } + return nil + } + + private func treeItemMatchesHandle(_ item: [String: Any], handle: String?) -> Bool { + guard let handle = handle?.trimmingCharacters(in: .whitespacesAndNewlines), !handle.isEmpty else { + return false + } + return (item["id"] as? String) == handle || (item["ref"] as? String) == handle + } + + private func renderTreeText(windows: [[String: Any]], idFormat: CLIIDFormat) -> String { + guard !windows.isEmpty else { return "No windows" } + + var lines: [String] = [] + for window in windows { + lines.append(treeWindowLabel(window, idFormat: idFormat)) + + let workspaces = window["workspaces"] as? [[String: Any]] ?? [] + for (workspaceIndex, workspace) in workspaces.enumerated() { + let workspaceIsLast = workspaceIndex == workspaces.count - 1 + let workspaceBranch = workspaceIsLast ? "└── " : "├── " + let workspaceIndent = workspaceIsLast ? " " : "│ " + lines.append("\(workspaceBranch)\(treeWorkspaceLabel(workspace, idFormat: idFormat))") + + let panes = workspace["panes"] as? [[String: Any]] ?? [] + for (paneIndex, pane) in panes.enumerated() { + let paneIsLast = paneIndex == panes.count - 1 + let paneBranch = paneIsLast ? "└── " : "├── " + let paneIndent = paneIsLast ? " " : "│ " + lines.append("\(workspaceIndent)\(paneBranch)\(treePaneLabel(pane, idFormat: idFormat))") + + let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] + for (surfaceIndex, surface) in surfaces.enumerated() { + let surfaceIsLast = surfaceIndex == surfaces.count - 1 + let surfaceBranch = surfaceIsLast ? "└── " : "├── " + lines.append("\(workspaceIndent)\(paneIndent)\(surfaceBranch)\(treeSurfaceLabel(surface, idFormat: idFormat))") + } + } + } + } + + return lines.joined(separator: "\n") + } + + private func treeWindowLabel(_ window: [String: Any], idFormat: CLIIDFormat) -> String { + var parts = ["window \(textHandle(window, idFormat: idFormat))"] + if (window["current"] as? Bool) == true { + parts.append("[current]") + } + if (window["active"] as? Bool) == true { + parts.append("◀ active") + } + return parts.joined(separator: " ") + } + + private func treeWorkspaceLabel(_ workspace: [String: Any], idFormat: CLIIDFormat) -> String { + var parts = ["workspace \(textHandle(workspace, idFormat: idFormat))"] + let title = (workspace["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + parts.append("\"\(title)\"") + } + if (workspace["selected"] as? Bool) == true { + parts.append("[selected]") + } + if (workspace["active"] as? Bool) == true { + parts.append("◀ active") + } + return parts.joined(separator: " ") + } + + private func treePaneLabel(_ pane: [String: Any], idFormat: CLIIDFormat) -> String { + var parts = ["pane \(textHandle(pane, idFormat: idFormat))"] + if (pane["focused"] as? Bool) == true { + parts.append("[focused]") + } + if (pane["active"] as? Bool) == true { + parts.append("◀ active") + } + return parts.joined(separator: " ") + } + + private func treeSurfaceLabel(_ surface: [String: Any], idFormat: CLIIDFormat) -> String { + let rawType = ((surface["type"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let surfaceType = rawType.isEmpty ? "unknown" : rawType + var parts = ["surface \(textHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"] + let title = (surface["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + parts.append("\"\(title)\"") + } + if (surface["selected"] as? Bool) == true { + parts.append("[selected]") + } + if (surface["active"] as? Bool) == true { + parts.append("◀ active") + } + if (surface["here"] as? Bool) == true { + parts.append("◀ here") + } + if surfaceType.lowercased() == "browser", + let url = surface["url"] as? String, + !url.isEmpty { + parts.append(url) + } + return parts.joined(separator: " ") + } + private func isUUID(_ value: String) -> Bool { return UUID(uuidString: value) != nil } private func jsonString(_ object: Any) -> String { + var options: JSONSerialization.WritingOptions = [.prettyPrinted] + options.insert(.withoutEscapingSlashes) guard JSONSerialization.isValidJSONObject(object), - let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]), + let data = try? JSONSerialization.data(withJSONObject: object, options: options), let output = String(data: data, encoding: .utf8) else { return "{}" } return output } + private struct TmuxParsedArguments { + var flags: Set<String> = [] + var options: [String: [String]] = [:] + var positional: [String] = [] + + func hasFlag(_ flag: String) -> Bool { + flags.contains(flag) + } + + func value(_ flag: String) -> String? { + options[flag]?.last + } + } + + private func parseTmuxArguments( + _ args: [String], + valueFlags: Set<String>, + boolFlags: Set<String> + ) throws -> TmuxParsedArguments { + var parsed = TmuxParsedArguments() + var index = 0 + var pastTerminator = false + + while index < args.count { + let arg = args[index] + if pastTerminator { + parsed.positional.append(arg) + index += 1 + continue + } + if arg == "--" { + pastTerminator = true + index += 1 + continue + } + if !arg.hasPrefix("-") || arg == "-" { + parsed.positional.append(arg) + index += 1 + continue + } + if arg.hasPrefix("--") { + parsed.positional.append(arg) + index += 1 + continue + } + + let cluster = Array(arg.dropFirst()) + var cursor = 0 + var recognizedArgument = false + while cursor < cluster.count { + let flag = "-" + String(cluster[cursor]) + if boolFlags.contains(flag) { + parsed.flags.insert(flag) + cursor += 1 + recognizedArgument = true + continue + } + if valueFlags.contains(flag) { + let remainder = String(cluster.dropFirst(cursor + 1)) + let value: String + if !remainder.isEmpty { + value = remainder + } else { + guard index + 1 < args.count else { + throw CLIError(message: "\(flag) requires a value") + } + index += 1 + value = args[index] + } + parsed.options[flag, default: []].append(value) + recognizedArgument = true + cursor = cluster.count + continue + } + + recognizedArgument = false + break + } + + if !recognizedArgument { + parsed.positional.append(arg) + } + index += 1 + } + + return parsed + } + + private func splitTmuxCommand(_ args: [String]) throws -> (command: String, args: [String]) { + var index = 0 + let globalValueFlags: Set<String> = ["-L", "-S", "-f"] + + while index < args.count { + let arg = args[index] + if !arg.hasPrefix("-") || arg == "-" { + return (arg.lowercased(), Array(args.dropFirst(index + 1))) + } + if arg == "--" { + break + } + if let flag = globalValueFlags.first(where: { arg == $0 || arg.hasPrefix($0) }) { + if arg == flag { + index += 1 + } + } + index += 1 + } + + throw CLIError(message: "tmux shim requires a command") + } + + private func normalizedTmuxTarget(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func tmuxWindowSelector(from raw: String?) -> String? { + guard let trimmed = normalizedTmuxTarget(raw) else { return nil } + if trimmed.hasPrefix("%") || trimmed.hasPrefix("pane:") { + return nil + } + if let dot = trimmed.lastIndex(of: ".") { + return String(trimmed[..<dot]) + } + return trimmed + } + + private func tmuxPaneSelector(from raw: String?) -> String? { + guard let trimmed = normalizedTmuxTarget(raw) else { return nil } + if trimmed.hasPrefix("%") { + return String(trimmed.dropFirst()) + } + if trimmed.hasPrefix("pane:") { + return trimmed + } + if let dot = trimmed.lastIndex(of: ".") { + return String(trimmed[trimmed.index(after: dot)...]) + } + return nil + } + + private func tmuxWorkspaceItems(client: SocketClient) throws -> [[String: Any]] { + let payload = try client.sendV2(method: "workspace.list") + return payload["workspaces"] as? [[String: Any]] ?? [] + } + + private func tmuxCallerWorkspaceHandle() -> String? { + normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]) + } + + private func tmuxCallerPaneHandle() -> String? { + guard let pane = normalizedTmuxTarget(ProcessInfo.processInfo.environment["TMUX_PANE"]) + ?? normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_PANE_ID"]) else { + return nil + } + return pane.hasPrefix("%") ? String(pane.dropFirst()) : pane + } + + private func tmuxCallerSurfaceHandle() -> String? { + normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]) + } + + private func tmuxCanonicalPaneId( + _ handle: String, + workspaceId: String, + client: SocketClient + ) throws -> String { + if isUUID(handle) { + return handle + } + + let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) + let panes = payload["panes"] as? [[String: Any]] ?? [] + for pane in panes { + if (pane["ref"] as? String) == handle || (pane["id"] as? String) == handle { + if let id = pane["id"] as? String { + return id + } + } + } + + if let index = Int(handle) { + for pane in panes where intFromAny(pane["index"]) == index { + if let id = pane["id"] as? String { + return id + } + } + } + + throw CLIError(message: "Pane target not found") + } + + private func tmuxCanonicalSurfaceId( + _ handle: String, + workspaceId: String, + client: SocketClient + ) throws -> String { + if isUUID(handle) { + return handle + } + + let payload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + for surface in surfaces { + if (surface["ref"] as? String) == handle || (surface["id"] as? String) == handle { + if let id = surface["id"] as? String { + return id + } + } + } + + if let index = Int(handle) { + for surface in surfaces where intFromAny(surface["index"]) == index { + if let id = surface["id"] as? String { + return id + } + } + } + + throw CLIError(message: "Surface target not found") + } + + private func tmuxWorkspaceIdForPaneHandle(_ handle: String, client: SocketClient) throws -> String? { + guard isUUID(handle) || isHandleRef(handle) else { + return nil + } + + let workspaces = try tmuxWorkspaceItems(client: client) + for workspace in workspaces { + guard let workspaceId = workspace["id"] as? String else { continue } + let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) + let panes = payload["panes"] as? [[String: Any]] ?? [] + if panes.contains(where: { ($0["id"] as? String) == handle || ($0["ref"] as? String) == handle }) { + return workspaceId + } + } + + return nil + } + + private func tmuxFocusedPaneId(workspaceId: String, client: SocketClient) throws -> String { + let payload = try client.sendV2(method: "surface.current", params: ["workspace_id": workspaceId]) + if let paneId = payload["pane_id"] as? String { + return paneId + } + if let paneRef = payload["pane_ref"] as? String { + return try tmuxCanonicalPaneId(paneRef, workspaceId: workspaceId, client: client) + } + throw CLIError(message: "Pane target not found") + } + + private func tmuxResolveWorkspaceTarget(_ raw: String?, client: SocketClient) throws -> String { + guard var token = normalizedTmuxTarget(raw) else { + if let callerWorkspace = tmuxCallerWorkspaceHandle() { + return try resolveWorkspaceId(callerWorkspace, client: client) + } + return try resolveWorkspaceId(nil, client: client) + } + + if token == "!" || token == "^" || token == "-" { + let payload = try client.sendV2(method: "workspace.last") + if let workspaceId = payload["workspace_id"] as? String { + return workspaceId + } + throw CLIError(message: "Previous workspace not found") + } + + if let dot = token.lastIndex(of: ".") { + token = String(token[..<dot]) + } + if let colon = token.lastIndex(of: ":") { + let suffix = token[token.index(after: colon)...] + token = suffix.isEmpty ? String(token[..<colon]) : String(suffix) + } + if token.hasPrefix("@") { + token = String(token.dropFirst()) + } + + if let resolvedHandle = try? normalizeWorkspaceHandle(token, client: client, allowCurrent: true) { + return try resolveWorkspaceId(resolvedHandle, client: client) + } + + let needle = token.trimmingCharacters(in: .whitespacesAndNewlines) + let items = try tmuxWorkspaceItems(client: client) + if let match = items.first(where: { + (($0["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == needle + }), let id = match["id"] as? String { + return id + } + + throw CLIError(message: "Workspace target not found: \(token)") + } + + private func tmuxResolvePaneTarget(_ raw: String?, client: SocketClient) throws -> (workspaceId: String, paneId: String) { + let paneSelector = tmuxPaneSelector(from: raw) + let workspaceSelector = tmuxWindowSelector(from: raw) + let workspaceId: String = { + if let workspaceSelector { + return (try? tmuxResolveWorkspaceTarget(workspaceSelector, client: client)) ?? "" + } + if let paneSelector, + let workspaceId = try? tmuxWorkspaceIdForPaneHandle(paneSelector, client: client) { + return workspaceId + } + return (try? tmuxResolveWorkspaceTarget(nil, client: client)) ?? "" + }() + guard !workspaceId.isEmpty else { + throw CLIError(message: "Workspace target not found") + } + let paneId: String + if let paneSelector { + paneId = try tmuxCanonicalPaneId(paneSelector, workspaceId: workspaceId, client: client) + } else if tmuxCallerWorkspaceHandle() == workspaceId, + let callerPane = tmuxCallerPaneHandle(), + let callerPaneId = try? tmuxCanonicalPaneId(callerPane, workspaceId: workspaceId, client: client) { + paneId = callerPaneId + } else { + paneId = try tmuxFocusedPaneId(workspaceId: workspaceId, client: client) + } + return (workspaceId, paneId) + } + + private func tmuxSelectedSurfaceId( + workspaceId: String, + paneId: String, + client: SocketClient + ) throws -> String { + let payload = try client.sendV2( + method: "pane.surfaces", + params: ["workspace_id": workspaceId, "pane_id": paneId] + ) + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + if let selected = surfaces.first(where: { ($0["selected"] as? Bool) == true }), + let id = selected["id"] as? String { + return id + } + if let first = surfaces.first?["id"] as? String { + return first + } + throw CLIError(message: "Pane has no surface to target") + } + + private func tmuxResolveSurfaceTarget( + _ raw: String?, + client: SocketClient + ) throws -> (workspaceId: String, paneId: String?, surfaceId: String) { + if tmuxPaneSelector(from: raw) != nil { + let resolved = try tmuxResolvePaneTarget(raw, client: client) + let surfaceId = try tmuxSelectedSurfaceId( + workspaceId: resolved.workspaceId, + paneId: resolved.paneId, + client: client + ) + return (resolved.workspaceId, resolved.paneId, surfaceId) + } + + let workspaceId = try tmuxResolveWorkspaceTarget(tmuxWindowSelector(from: raw), client: client) + if tmuxWindowSelector(from: raw) == nil, + tmuxCallerWorkspaceHandle() == workspaceId, + let callerSurface = tmuxCallerSurfaceHandle(), + let surfaceId = try? tmuxCanonicalSurfaceId(callerSurface, workspaceId: workspaceId, client: client) { + return (workspaceId, nil, surfaceId) + } + let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) + return (workspaceId, nil, surfaceId) + } + + private func tmuxRenderFormat( + _ format: String?, + context: [String: String], + fallback: String + ) -> String { + guard let format, !format.isEmpty else { return fallback } + var rendered = format + for (key, value) in context { + rendered = rendered.replacingOccurrences(of: "#{\(key)}", with: value) + } + rendered = rendered.replacingOccurrences( + of: "#\\{[^}]+\\}", + with: "", + options: .regularExpression + ) + let trimmed = rendered.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallback : trimmed + } + + private func tmuxFormatContext( + workspaceId: String, + paneId: String? = nil, + surfaceId: String? = nil, + client: SocketClient + ) throws -> [String: String] { + let canonicalWorkspaceId = try resolveWorkspaceId(workspaceId, client: client) + var context: [String: String] = [ + "session_name": "cmux", + "window_id": "@\(canonicalWorkspaceId)", + "window_uuid": canonicalWorkspaceId + ] + + let workspaceItems = try tmuxWorkspaceItems(client: client) + if let workspace = workspaceItems.first(where: { + ($0["id"] as? String) == canonicalWorkspaceId || ($0["ref"] as? String) == workspaceId + }) { + if let index = intFromAny(workspace["index"]) { + context["window_index"] = String(index) + } + let title = ((workspace["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty { + context["window_name"] = title + } + } + + let currentPayload = try client.sendV2(method: "surface.current", params: ["workspace_id": canonicalWorkspaceId]) + let resolvedPaneId: String? = try { + if let paneId { + return try tmuxCanonicalPaneId(paneId, workspaceId: canonicalWorkspaceId, client: client) + } + if let currentPaneId = currentPayload["pane_id"] as? String { + return currentPaneId + } + if let currentPaneRef = currentPayload["pane_ref"] as? String { + return try tmuxCanonicalPaneId(currentPaneRef, workspaceId: canonicalWorkspaceId, client: client) + } + return nil + }() + let resolvedSurfaceId: String? = try { + if let surfaceId { + return try tmuxCanonicalSurfaceId(surfaceId, workspaceId: canonicalWorkspaceId, client: client) + } + if let resolvedPaneId { + return try tmuxSelectedSurfaceId( + workspaceId: canonicalWorkspaceId, + paneId: resolvedPaneId, + client: client + ) + } + return currentPayload["surface_id"] as? String + }() + + if let resolvedPaneId { + context["pane_id"] = "%\(resolvedPaneId)" + context["pane_uuid"] = resolvedPaneId + let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": canonicalWorkspaceId]) + let panes = panePayload["panes"] as? [[String: Any]] ?? [] + if let pane = panes.first(where: { ($0["id"] as? String) == resolvedPaneId }), + let index = intFromAny(pane["index"]) { + context["pane_index"] = String(index) + } + } + + if let resolvedSurfaceId { + context["surface_id"] = resolvedSurfaceId + let surfacePayload = try client.sendV2(method: "surface.list", params: ["workspace_id": canonicalWorkspaceId]) + let surfaces = surfacePayload["surfaces"] as? [[String: Any]] ?? [] + if let surface = surfaces.first(where: { ($0["id"] as? String) == resolvedSurfaceId }) { + let title = ((surface["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty { + context["pane_title"] = title + context["window_name"] = context["window_name"] ?? title + } + } + } + + return context + } + + private func tmuxShellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private func tmuxShellCommandText(commandTokens: [String], cwd: String?) -> String? { + let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) + let commandText = commandTokens.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard (trimmedCwd?.isEmpty == false) || !commandText.isEmpty else { + return nil + } + + var pieces: [String] = [] + if let trimmedCwd, !trimmedCwd.isEmpty { + pieces.append("cd -- \(tmuxShellQuote(resolvePath(trimmedCwd)))") + } + if !commandText.isEmpty { + pieces.append(commandText) + } + return pieces.joined(separator: " && ") + "\r" + } + + private func tmuxSpecialKeyText(_ token: String) -> String? { + switch token.lowercased() { + case "enter", "c-m", "kpenter": + return "\r" + case "tab", "c-i": + return "\t" + case "space": + return " " + case "bspace", "backspace": + return "\u{7f}" + case "escape", "esc", "c-[": + return "\u{1b}" + case "c-c": + return "\u{03}" + case "c-d": + return "\u{04}" + case "c-z": + return "\u{1a}" + case "c-l": + return "\u{0c}" + default: + return nil + } + } + + private func tmuxSendKeysText(from tokens: [String], literal: Bool) -> String { + if literal { + return tokens.joined(separator: " ") + } + + var result = "" + var pendingSpace = false + for token in tokens { + if let special = tmuxSpecialKeyText(token) { + result += special + pendingSpace = false + continue + } + if pendingSpace { + result += " " + } + result += token + pendingSpace = true + } + return result + } + + private func prependPathEntries(_ newEntries: [String], to currentPath: String?) -> String { + var ordered: [String] = [] + var seen: Set<String> = [] + for entry in newEntries + (currentPath?.split(separator: ":").map(String.init) ?? []) where !entry.isEmpty { + if seen.insert(entry).inserted { + ordered.append(entry) + } + } + return ordered.joined(separator: ":") + } + + private struct ClaudeTeamsFocusedContext { + let socketPath: String + let workspaceId: String + let windowId: String? + let paneHandle: String + let paneId: String? + let surfaceId: String? + } + + private func claudeTeamsResolvedSocketPath(processEnvironment: [String: String]) -> String { + let envSocketPath: String? = { + for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] { + guard let raw = processEnvironment[key] else { continue } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return nil + }() + + let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath + let source: CLISocketPathSource + if let envSocketPath { + source = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment + } else { + source = .implicitDefault + } + + return CLISocketPathResolver.resolve( + requestedPath: requestedSocketPath, + source: source, + environment: processEnvironment + ) + } + + private func claudeTeamsFocusedContext( + processEnvironment: [String: String], + explicitPassword: String? + ) -> ClaudeTeamsFocusedContext? { + let socketPath = claudeTeamsResolvedSocketPath(processEnvironment: processEnvironment) + let client = SocketClient(path: socketPath) + + do { + try client.connect() + try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + defer { client.close() } + + let payload = try client.sendV2(method: "system.identify") + let focused = payload["focused"] as? [String: Any] ?? [:] + + let workspaceId = (focused["workspace_id"] as? String) + ?? (focused["workspace_ref"] as? String) + let paneId = (focused["pane_id"] as? String) + ?? (focused["pane_ref"] as? String) + + guard let workspaceId, let paneId else { + return nil + } + + let paneHandle = paneId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !paneHandle.isEmpty else { + return nil + } + + let windowId = (focused["window_id"] as? String) + ?? (focused["window_ref"] as? String) + let surfaceId = (focused["surface_id"] as? String) + ?? (focused["surface_ref"] as? String) + + return ClaudeTeamsFocusedContext( + socketPath: socketPath, + workspaceId: workspaceId, + windowId: windowId, + paneHandle: paneHandle, + paneId: focused["pane_id"] as? String, + surfaceId: surfaceId + ) + } catch { + client.close() + return nil + } + } + + private func isCmuxClaudeWrapper(at path: String) -> Bool { + guard let data = FileManager.default.contents(atPath: path) else { return false } + let prefixData = data.prefix(512) + guard let prefix = String(data: prefixData, encoding: .utf8) else { return false } + return prefix.contains("cmux claude wrapper - injects hooks and session tracking") + } + + private func resolveClaudeExecutable(searchPath: String?) -> String? { + let entries = searchPath?.split(separator: ":").map(String.init) ?? [] + for entry in entries where !entry.isEmpty { + let candidate = URL(fileURLWithPath: entry, isDirectory: true) + .appendingPathComponent("claude", isDirectory: false) + .path + guard FileManager.default.isExecutableFile(atPath: candidate) else { continue } + guard !isCmuxClaudeWrapper(at: candidate) else { continue } + return candidate + } + return nil + } + + private func claudeTeamsHasExplicitTeammateMode(commandArgs: [String]) -> Bool { + commandArgs.contains { arg in + arg == "--teammate-mode" || arg.hasPrefix("--teammate-mode=") + } + } + + private func claudeTeamsLaunchArguments(commandArgs: [String]) -> [String] { + guard !claudeTeamsHasExplicitTeammateMode(commandArgs: commandArgs) else { + return commandArgs + } + return ["--teammate-mode", "auto"] + commandArgs + } + + private func configureClaudeTeamsEnvironment( + processEnvironment: [String: String], + shimDirectory: URL, + executablePath: String, + socketPath: String, + explicitPassword: String?, + focusedContext: ClaudeTeamsFocusedContext? + ) { + let updatedPath = prependPathEntries( + [shimDirectory.path], + to: processEnvironment["PATH"] + ) + let fakeTmuxValue: String = { + if let focusedContext { + let windowToken = focusedContext.windowId ?? focusedContext.workspaceId + return "/tmp/cmux-claude-teams/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)" + } + return processEnvironment["TMUX"] ?? "/tmp/cmux-claude-teams/default,0,0" + }() + let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" } + ?? processEnvironment["TMUX_PANE"] + ?? "%1" + let fakeTerm = processEnvironment["CMUX_CLAUDE_TEAMS_TERM"] ?? "screen-256color" + + setenv("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "1", 1) + setenv("CMUX_CLAUDE_TEAMS_CMUX_BIN", executablePath, 1) + setenv("PATH", updatedPath, 1) + setenv("TMUX", fakeTmuxValue, 1) + setenv("TMUX_PANE", fakeTmuxPane, 1) + setenv("TERM", fakeTerm, 1) + setenv("CMUX_SOCKET_PATH", socketPath, 1) + setenv("CMUX_SOCKET", socketPath, 1) + if let explicitPassword, + !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1) + } + unsetenv("TERM_PROGRAM") + if let focusedContext { + setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1) + if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty { + setenv("CMUX_SURFACE_ID", surfaceId, 1) + } + } + } + + private func createClaudeTeamsShimDirectory() throws -> URL { + let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + let rootPath = URL(fileURLWithPath: homePath, isDirectory: true) + .appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("claude-teams-bin", isDirectory: true) + .path + let root = URL(fileURLWithPath: rootPath, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) + let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false) + let script = """ + #!/usr/bin/env bash + set -euo pipefail + exec "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}" __tmux-compat "$@" + """ + let normalizedScript = script.trimmingCharacters(in: .whitespacesAndNewlines) + let existingScript = try? String(contentsOf: tmuxURL, encoding: .utf8) + if existingScript?.trimmingCharacters(in: .whitespacesAndNewlines) != normalizedScript { + try script.write(to: tmuxURL, atomically: false, encoding: .utf8) + } + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmuxURL.path) + return root + } + + private func runClaudeTeams( + commandArgs: [String], + socketPath: String, + explicitPassword: String? + ) throws { + let processEnvironment = ProcessInfo.processInfo.environment + var launcherEnvironment = processEnvironment + launcherEnvironment["CMUX_SOCKET_PATH"] = socketPath + launcherEnvironment["CMUX_SOCKET"] = socketPath + if let explicitPassword, + !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + launcherEnvironment["CMUX_SOCKET_PASSWORD"] = explicitPassword + } + let shimDirectory = try createClaudeTeamsShimDirectory() + let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux") + let focusedContext = claudeTeamsFocusedContext( + processEnvironment: launcherEnvironment, + explicitPassword: explicitPassword + ) + let bundledClaudePath = resolvedExecutableURL()? + .deletingLastPathComponent() + .appendingPathComponent("claude", isDirectory: false) + .path + let claudeExecutablePath = resolveClaudeExecutable(searchPath: launcherEnvironment["PATH"]) + ?? { + guard let bundledClaudePath, + FileManager.default.isExecutableFile(atPath: bundledClaudePath) else { return nil } + return bundledClaudePath + }() + configureClaudeTeamsEnvironment( + processEnvironment: launcherEnvironment, + shimDirectory: shimDirectory, + executablePath: executablePath, + socketPath: socketPath, + explicitPassword: explicitPassword, + focusedContext: focusedContext + ) + + let launchPath = claudeExecutablePath ?? "claude" + let launchArguments = claudeTeamsLaunchArguments(commandArgs: commandArgs) + var argv = ([launchPath] + launchArguments).map { strdup($0) } + defer { + for item in argv { + free(item) + } + } + argv.append(nil) + + if claudeExecutablePath != nil { + execv(launchPath, &argv) + } else { + execvp("claude", &argv) + } + let code = errno + throw CLIError(message: "Failed to launch claude: \(String(cString: strerror(code)))") + } + + private func runClaudeTeamsTmuxCompat( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + let (command, rawArgs) = try splitTmuxCommand(commandArgs) + + switch command { + case "new-session", "new": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-c", "-F", "-n", "-s"], + boolFlags: ["-A", "-d", "-P"] + ) + if parsed.hasFlag("-A") { + throw CLIError(message: "new-session -A is not supported in cmux claude-teams mode") + } + var params: [String: Any] = ["focus": false] + if let cwd = parsed.value("-c") { + params["cwd"] = resolvePath(cwd) + } + let created = try client.sendV2(method: "workspace.create", params: params) + guard let workspaceId = created["workspace_id"] as? String else { + throw CLIError(message: "workspace.create did not return workspace_id") + } + if let title = parsed.value("-n") ?? parsed.value("-s"), + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": title + ]) + } + if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { + Thread.sleep(forTimeInterval: 0.3) + let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) + _ = try client.sendV2(method: "surface.send_text", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "text": text + ]) + } + if parsed.hasFlag("-P") { + let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)")) + } + + case "new-window", "neww": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-c", "-F", "-n", "-t"], + boolFlags: ["-d", "-P"] + ) + if parsed.value("-t") != nil { + throw CLIError(message: "new-window -t is not supported in cmux claude-teams mode") + } + var params: [String: Any] = ["focus": false] + if let cwd = parsed.value("-c") { + params["cwd"] = resolvePath(cwd) + } + let created = try client.sendV2(method: "workspace.create", params: params) + guard let workspaceId = created["workspace_id"] as? String else { + throw CLIError(message: "workspace.create did not return workspace_id") + } + if let title = parsed.value("-n"), + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": title + ]) + } + if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { + Thread.sleep(forTimeInterval: 0.3) + let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) + _ = try client.sendV2(method: "surface.send_text", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "text": text + ]) + } + if parsed.hasFlag("-P") { + let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)")) + } + + case "split-window", "splitw": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-c", "-F", "-l", "-t"], + boolFlags: ["-P", "-b", "-d", "-h", "-v"] + ) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + let direction: String + if parsed.hasFlag("-h") { + direction = parsed.hasFlag("-b") ? "left" : "right" + } else { + direction = parsed.hasFlag("-b") ? "up" : "down" + } + let created = try client.sendV2(method: "surface.split", params: [ + "workspace_id": target.workspaceId, + "surface_id": target.surfaceId, + "direction": direction + ]) + guard let surfaceId = created["surface_id"] as? String else { + throw CLIError(message: "surface.split did not return surface_id") + } + let paneId = created["pane_id"] as? String + // Keep the leader pane focused while Claude starts teammates beside it. + if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { + Thread.sleep(forTimeInterval: 0.3) + _ = try client.sendV2(method: "surface.send_text", params: [ + "workspace_id": target.workspaceId, + "surface_id": surfaceId, + "text": text + ]) + } + if parsed.hasFlag("-P") { + let context = try tmuxFormatContext( + workspaceId: target.workspaceId, + paneId: paneId, + surfaceId: surfaceId, + client: client + ) + let fallback = context["pane_id"] ?? surfaceId + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) + } + + case "select-window", "selectw": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "workspace.select", params: ["workspace_id": workspaceId]) + + case "select-pane", "selectp": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-P", "-T", "-t"], boolFlags: []) + if parsed.value("-P") != nil || parsed.value("-T") != nil { + return + } + let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "pane.focus", params: [ + "workspace_id": target.workspaceId, + "pane_id": target.paneId + ]) + + case "kill-window", "killw": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) + + case "kill-pane", "killp": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "surface.close", params: [ + "workspace_id": target.workspaceId, + "surface_id": target.surfaceId + ]) + + case "send-keys", "send": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: ["-l"]) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + let text = tmuxSendKeysText(from: parsed.positional, literal: parsed.hasFlag("-l")) + if !text.isEmpty { + _ = try client.sendV2(method: "surface.send_text", params: [ + "workspace_id": target.workspaceId, + "surface_id": target.surfaceId, + "text": text + ]) + } + + case "capture-pane", "capturep": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-E", "-S", "-t"], + boolFlags: ["-J", "-N", "-p"] + ) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + var params: [String: Any] = [ + "workspace_id": target.workspaceId, + "surface_id": target.surfaceId, + "scrollback": true + ] + if let start = parsed.value("-S"), let lines = Int(start), lines < 0 { + params["lines"] = abs(lines) + } + let payload = try client.sendV2(method: "surface.read_text", params: params) + let text = (payload["text"] as? String) ?? "" + if parsed.hasFlag("-p") { + print(text) + } else { + var store = loadTmuxCompatStore() + store.buffers["default"] = text + try saveTmuxCompatStore(store) + } + + case "display-message", "display", "displayp": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: ["-p"]) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + let context = try tmuxFormatContext( + workspaceId: target.workspaceId, + paneId: target.paneId, + surfaceId: target.surfaceId, + client: client + ) + let format = parsed.positional.isEmpty ? parsed.value("-F") : parsed.positional.joined(separator: " ") + let rendered = tmuxRenderFormat(format, context: context, fallback: "") + if parsed.hasFlag("-p") || !rendered.isEmpty { + print(rendered) + } + + case "list-windows", "lsw": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: []) + let items = try tmuxWorkspaceItems(client: client) + for item in items { + guard let workspaceId = item["id"] as? String else { continue } + let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) + let fallback = [ + context["window_index"] ?? "?", + context["window_name"] ?? workspaceId + ].joined(separator: " ") + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) + } + + case "list-panes", "lsp": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: []) + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) + let panes = payload["panes"] as? [[String: Any]] ?? [] + for pane in panes { + guard let paneId = pane["id"] as? String else { continue } + let context = try tmuxFormatContext(workspaceId: workspaceId, paneId: paneId, client: client) + let fallback = context["pane_id"] ?? paneId + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) + } + + case "rename-window", "renamew": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let title = parsed.positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw CLIError(message: "rename-window requires a title") + } + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": title + ]) + + case "resize-pane", "resizep": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-t", "-x", "-y"], + boolFlags: ["-D", "-L", "-R", "-U"] + ) + let hasDirectionalFlags = parsed.hasFlag("-L") + || parsed.hasFlag("-R") + || parsed.hasFlag("-U") + || parsed.hasFlag("-D") + if !hasDirectionalFlags { + return + } + let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client) + let direction: String + if parsed.hasFlag("-L") { + direction = "left" + } else if parsed.hasFlag("-U") { + direction = "up" + } else if parsed.hasFlag("-D") { + direction = "down" + } else { + direction = "right" + } + let rawAmount = (parsed.value("-x") ?? parsed.value("-y") ?? "5") + .replacingOccurrences(of: "%", with: "") + let amount = Int(rawAmount) ?? 5 + _ = try client.sendV2(method: "pane.resize", params: [ + "workspace_id": target.workspaceId, + "pane_id": target.paneId, + "direction": direction, + "amount": max(1, amount) + ]) + + case "wait-for": + try runTmuxCompatCommand( + command: "wait-for", + commandArgs: rawArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + + case "last-pane": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "pane.last", params: ["workspace_id": workspaceId]) + + case "show-buffer", "showb": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: []) + let name = parsed.value("-b") ?? "default" + let store = loadTmuxCompatStore() + if let buffer = store.buffers[name] { + print(buffer) + } + + case "save-buffer", "saveb": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: []) + let name = parsed.value("-b") ?? "default" + let store = loadTmuxCompatStore() + guard let buffer = store.buffers[name] else { + throw CLIError(message: "Buffer not found: \(name)") + } + if let outputPath = parsed.positional.last, !outputPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try buffer.write(toFile: resolvePath(outputPath), atomically: true, encoding: .utf8) + } else { + print(buffer) + } + + case "last-window", "next-window", "previous-window", "set-hook", "set-buffer", "list-buffers": + try runTmuxCompatCommand( + command: command, + commandArgs: rawArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + + case "has-session", "has": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + _ = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + + case "select-layout", "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client": + return + + default: + throw CLIError(message: "Unsupported tmux compatibility command: \(command)") + } + } + private struct TmuxCompatStore: Codable { var buffers: [String: String] = [:] var hooks: [String: String] = [:] @@ -4035,7 +7547,11 @@ struct CMUXCLI { } } - private func runClaudeHook(commandArgs: [String], client: SocketClient) throws { + private func runClaudeHook( + commandArgs: [String], + client: SocketClient, + telemetry: CLISocketSentryTelemetry + ) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) let hookWsFlag = optionValue(hookArgs, name: "--workspace") @@ -4044,11 +7560,21 @@ struct CMUXCLI { let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" let parsedInput = parseClaudeHookInput(rawInput: rawInput) let sessionStore = ClaudeHookSessionStore() + telemetry.breadcrumb( + "claude-hook.input", + data: [ + "subcommand": subcommand, + "has_session_id": parsedInput.sessionId != nil, + "has_workspace_flag": hookWsFlag != nil, + "has_surface_flag": optionValue(hookArgs, name: "--surface") != nil + ] + ) let fallbackWorkspaceId = try resolveWorkspaceIdForClaudeHook(workspaceArg, client: client) let fallbackSurfaceId = try? resolveSurfaceId(surfaceArg, workspaceId: fallbackWorkspaceId, client: client) switch subcommand { case "session-start", "active": + telemetry.breadcrumb("claude-hook.session-start") let workspaceId = fallbackWorkspaceId let surfaceId = try resolveSurfaceIdForClaudeHook( surfaceArg, @@ -4073,6 +7599,7 @@ struct CMUXCLI { print("OK") case "stop", "idle": + telemetry.breadcrumb("claude-hook.stop") let consumedSession = try? sessionStore.consume( sessionId: parsedInput.sessionId, workspaceId: fallbackWorkspaceId, @@ -4100,7 +7627,26 @@ struct CMUXCLI { print("OK") } + case "prompt-submit": + telemetry.breadcrumb("claude-hook.prompt-submit") + var workspaceId = fallbackWorkspaceId + if let sessionId = parsedInput.sessionId, + let mapped = try? sessionStore.lookup(sessionId: sessionId), + let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) { + workspaceId = mappedWorkspace + } + _ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) + try setClaudeStatus( + client: client, + workspaceId: workspaceId, + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" + ) + print("OK") + case "notification", "notify": + telemetry.breadcrumb("claude-hook.notification") let summary = summarizeClaudeHookNotification(rawInput: rawInput) var workspaceId = fallbackWorkspaceId @@ -4145,6 +7691,7 @@ struct CMUXCLI { print(response) case "help", "--help", "-h": + telemetry.breadcrumb("claude-hook.help") print( """ cmux claude-hook <session-start|stop|notification> [--workspace <id|index>] [--surface <id|index>] @@ -4470,39 +8017,131 @@ struct CMUXCLI { private func versionSummary() -> String { let info = resolvedVersionInfo() + let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) } + let baseSummary: String if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] { - return "cmux \(version) (\(build))" + baseSummary = "cmux \(version) (\(build))" + } else if let version = info["CFBundleShortVersionString"] { + baseSummary = "cmux \(version)" + } else if let build = info["CFBundleVersion"] { + baseSummary = "cmux build \(build)" + } else { + baseSummary = "cmux version unknown" } - if let version = info["CFBundleShortVersionString"] { - return "cmux \(version)" + guard let commit else { return baseSummary } + return "\(baseSummary) [\(commit)]" + } + + private func printWelcome() { + let reset = "\u{001B}[0m" + let bold = "\u{001B}[1m" + func trueColor(_ red: Int, _ green: Int, _ blue: Int) -> String { + "\u{001B}[38;2;\(red);\(green);\(blue)m" } - if let build = info["CFBundleVersion"] { - return "cmux build \(build)" + + let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" + + let c1 = trueColor(0, 212, 255) + let c2 = trueColor(24, 181, 250) + let c3 = trueColor(48, 150, 245) + let c4 = trueColor(72, 119, 241) + let c5 = trueColor(96, 88, 239) + let c6 = trueColor(110, 73, 238) + let c7 = trueColor(124, 58, 237) + + let tagline: String + let subdued: String + + if isDark { + tagline = trueColor(130, 130, 140) + subdued = "\u{001B}[2m" + } else { + tagline = trueColor(90, 90, 98) + subdued = trueColor(100, 100, 108) } - return "cmux version unknown" + + let logo = """ + \(c1) ::\(reset) + \(c2) ::::\(reset) \(c1)c\(c2)m\(c3)u\(c7)x\(reset) + \(c3) ::::::\(reset) + \(c4) ::::::\(reset) \(tagline)the open source terminal\(reset) + \(c5) ::::::\(reset) \(tagline)built for coding agents\(reset) + \(c6) ::::\(reset) + \(c7) ::\(reset) + """ + + let shortcuts = """ + \(bold)Shortcuts\(reset) + + \(bold)\u{2318}N\(reset)\(subdued) New workspace\(reset) + \(bold)\u{2318}T\(reset)\(subdued) New tab\(reset) + \(bold)\u{2318}P\(reset)\(subdued) Go to workspace\(reset) + \(bold)\u{2318}D\(reset)\(subdued) Split right\(reset) + \(bold)\u{2318}\u{21E7}D\(reset)\(subdued) Split down\(reset) + \(bold)\u{2318}\u{21E7}P\(reset)\(subdued) Command palette\(reset) + \(bold)\u{2318}\u{21E7}R\(reset)\(subdued) Rename workspace\(reset) + \(bold)\u{2318}\u{21E7}L\(reset)\(subdued) New browser\(reset) + \(bold)\u{2318}\u{21E7}U\(reset)\(subdued) Jump to latest unread\(reset) + """ + + print() + print(logo) + print() + print(shortcuts) + print() + print(" \(bold)Docs\(reset)\(subdued) https://cmux.dev/docs\(reset)") + print(" \(bold)Discord\(reset)\(subdued) https://discord.gg/xsgFEVrWCZ\(reset)") + print(" \(bold)GitHub\(reset)\(subdued) https://github.com/manaflow-ai/cmux (please leave a star ⭐)\(reset)") + print(" \(bold)Email\(reset)\(subdued) founders@manaflow.com\(reset)") + print() + print(" \(subdued)Run \(reset)\(bold)cmux --help\(reset)\(subdued) for all commands.\(reset)") + print(" \(subdued)Run \(reset)\(bold)cmux shortcuts\(reset)\(subdued) to edit shortcuts.\(reset)") + print(" \(subdued)Run \(reset)\(bold)cmux feedback\(reset)\(subdued) to report a bug.\(reset)") + print() } private func resolvedVersionInfo() -> [String: String] { + var info: [String: String] = [:] if let main = versionInfo(from: Bundle.main.infoDictionary) { - return main + info.merge(main, uniquingKeysWith: { current, _ in current }) } - for plistURL in candidateInfoPlistURLs() { - guard let data = try? Data(contentsOf: plistURL), - let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), - let dictionary = raw as? [String: Any], - let parsed = versionInfo(from: dictionary) - else { - continue + let needsPlistFallback = + info["CFBundleShortVersionString"] == nil || + info["CFBundleVersion"] == nil || + info["CMUXCommit"] == nil + if needsPlistFallback { + for plistURL in candidateInfoPlistURLs() { + guard let data = try? Data(contentsOf: plistURL), + let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), + let dictionary = raw as? [String: Any], + let parsed = versionInfo(from: dictionary) + else { + continue + } + info.merge(parsed, uniquingKeysWith: { current, _ in current }) + if info["CFBundleShortVersionString"] != nil, + info["CFBundleVersion"] != nil, + info["CMUXCommit"] != nil { + break + } } - return parsed } - if let fromProject = versionInfoFromProjectFile() { - return fromProject + let needsProjectFallback = + info["CFBundleShortVersionString"] == nil || + info["CFBundleVersion"] == nil || + info["CMUXCommit"] == nil + if needsProjectFallback, let fromProject = versionInfoFromProjectFile() { + info.merge(fromProject, uniquingKeysWith: { current, _ in current }) } - return [:] + if info["CMUXCommit"] == nil, + let commit = normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"]) { + info["CMUXCommit"] = commit + } + + return info } private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? { @@ -4521,19 +8160,20 @@ struct CMUXCLI { info["CFBundleVersion"] = trimmed } } + if let commit = dictionary["CMUXCommit"] as? String, + let normalizedCommit = normalizedCommitHash(commit) { + info["CMUXCommit"] = normalizedCommit + } return info.isEmpty ? nil : info } private func versionInfoFromProjectFile() -> [String: String]? { - guard let executable = currentExecutablePath(), !executable.isEmpty else { + guard let executableURL = resolvedExecutableURL() else { return nil } let fileManager = FileManager.default - var current = URL(fileURLWithPath: executable) - .resolvingSymlinksInPath() - .standardizedFileURL - .deletingLastPathComponent() + var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") @@ -4546,13 +8186,15 @@ struct CMUXCLI { if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) { info["CFBundleVersion"] = build } + if let commit = gitCommitHash(at: current) { + info["CMUXCommit"] = commit + } if !info.isEmpty { return info } } - let parent = current.deletingLastPathComponent() - if parent.path == current.path { + guard let parent = parentSearchURL(for: current) else { break } current = parent @@ -4582,24 +8224,85 @@ struct CMUXCLI { return value } + private func gitCommitHash(at directory: URL) -> String? { + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"] + process.standardOutput = stdout + process.standardError = Pipe() + + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { + return nil + } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { + return nil + } + return normalizedCommitHash(output) + } + + private func normalizedCommitHash(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("$(") else { + return nil + } + let normalized = trimmed.lowercased() + let allowed = CharacterSet(charactersIn: "0123456789abcdef") + guard normalized.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { + return nil + } + return String(normalized.prefix(12)) + } + + // Foundation can walk past "/" into "/.." when repeatedly deleting path + // components, so stop once the canonical root is reached. + private func parentSearchURL(for url: URL) -> URL? { + let standardized = url.standardizedFileURL + let path = standardized.path + guard !path.isEmpty, path != "/" else { + return nil + } + + let parent = standardized.deletingLastPathComponent().standardizedFileURL + guard parent.path != path else { + return nil + } + return parent + } + private func candidateInfoPlistURLs() -> [URL] { - guard let executable = currentExecutablePath(), !executable.isEmpty else { + guard let executableURL = resolvedExecutableURL() else { return [] } let fileManager = FileManager.default - let executableURL = URL(fileURLWithPath: executable) - .resolvingSymlinksInPath() - .standardizedFileURL var candidates: [URL] = [] - var current = executableURL.deletingLastPathComponent() + var seen: Set<String> = [] + func appendIfExisting(_ url: URL) { + let path = url.path + guard !path.isEmpty else { return } + guard seen.insert(path).inserted else { return } + guard fileManager.fileExists(atPath: path) else { return } + candidates.append(url) + } + + var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { if current.pathExtension == "app" { - candidates.append(current.appendingPathComponent("Contents/Info.plist")) + appendIfExisting(current.appendingPathComponent("Contents/Info.plist")) } if current.lastPathComponent == "Contents" { - candidates.append(current.appendingPathComponent("Info.plist")) + appendIfExisting(current.appendingPathComponent("Info.plist")) } // Local dev fallback: resolve version from the repo's app Info.plist @@ -4608,41 +8311,41 @@ struct CMUXCLI { let repoInfo = current.appendingPathComponent("Resources/Info.plist") if fileManager.fileExists(atPath: projectMarker.path), fileManager.fileExists(atPath: repoInfo.path) { - candidates.append(repoInfo) + appendIfExisting(repoInfo) break } - let parent = current.deletingLastPathComponent() - if parent.path == current.path { + guard let parent = parentSearchURL(for: current) else { break } current = parent } + // If we already found an ancestor bundle or repo Info.plist, avoid scanning + // sibling app bundles. Large Resources directories can otherwise balloon RSS. + guard candidates.isEmpty else { + return candidates + } + let searchRoots = [ - executableURL.deletingLastPathComponent(), - executableURL.deletingLastPathComponent().deletingLastPathComponent() + executableURL.deletingLastPathComponent().standardizedFileURL, + executableURL.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL ] for root in searchRoots { - guard let entries = try? fileManager.contentsOfDirectory( + guard let entries = fileManager.enumerator( at: root, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants], + errorHandler: { _, _ in true } ) else { continue } - for entry in entries where entry.pathExtension == "app" { - candidates.append(entry.appendingPathComponent("Contents/Info.plist")) + for case let entry as URL in entries where entry.pathExtension == "app" { + appendIfExisting(entry.appendingPathComponent("Contents/Info.plist")) } } - var seen: Set<String> = [] - return candidates.filter { url in - let path = url.path - guard !path.isEmpty else { return false } - guard seen.insert(path).inserted else { return false } - return fileManager.fileExists(atPath: path) - } + return candidates } private func currentExecutablePath() -> String? { @@ -4660,23 +8363,42 @@ struct CMUXCLI { return Bundle.main.executableURL?.path ?? args.first } + private func resolvedExecutableURL() -> URL? { + guard let executable = currentExecutablePath(), !executable.isEmpty else { + return nil + } + + let expanded = (executable as NSString).expandingTildeInPath + if let resolvedPath = realpath(expanded, nil) { + defer { free(resolvedPath) } + return URL(fileURLWithPath: String(cString: resolvedPath)).standardizedFileURL + } + + return URL(fileURLWithPath: expanded).standardizedFileURL + } + private func usage() -> String { return """ cmux - control cmux via Unix socket Usage: - cmux [--socket PATH] [--window WINDOW] [--password PASSWORD] [--json] [--id-format refs|uuids|both] [--version] <command> [options] + cmux <path> Open a directory in a new workspace (launches cmux if needed) + cmux [global-options] <command> [options] Handle Inputs: - For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes. + Use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes where commands accept window, workspace, pane, or surface inputs. `tab-action` also accepts `tab:<n>` in addition to `surface:<n>`. Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. Socket Auth: - --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then keychain password saved in Settings. + --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings. Commands: version + welcome + shortcuts + feedback [--email <email> --body <text> [--image <path> ...]] + claude-teams [claude-args...] ping capabilities identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] @@ -4689,10 +8411,11 @@ struct CMUXCLI { reorder-workspace --workspace <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--window <id|ref|index>] workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] list-workspaces - new-workspace [--command <text>] + new-workspace [--cwd <path>] [--command <text>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] list-panes [--workspace <id|ref>] list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] + tree [--all] [--workspace <id|ref|index>] focus-pane --pane <id|ref> [--workspace <id|ref>] new-pane [--type <terminal|browser>] [--direction <left|right|up|down>] [--workspace <id|ref>] [--url <url>] new-surface [--type <terminal|browser>] [--pane <id|ref>] [--workspace <id|ref>] [--url <url>] @@ -4721,6 +8444,18 @@ struct CMUXCLI { list-notifications clear-notifications claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>] + + # sidebar metadata commands + set-status <key> <value> [--icon <name>] [--color <#hex>] [--workspace <id|ref>] + clear-status <key> [--workspace <id|ref>] + list-status [--workspace <id|ref>] + set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>] + clear-progress [--workspace <id|ref>] + log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message> + clear-log [--workspace <id|ref>] + list-log [--limit <n>] [--workspace <id|ref>] + sidebar-state [--workspace <id|ref>] + set-app-focus <active|inactive|clear> simulate-app-active @@ -4745,6 +8480,8 @@ struct CMUXCLI { respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd>] display-message [-p|--print] <text> + markdown [open] <path> (open markdown file in formatted viewer panel with live reload) + browser [--surface <id|ref|index> | <surface>] <subcommand> ... browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate) browser open-split [url] @@ -4760,6 +8497,7 @@ struct CMUXCLI { browser press|keydown|keyup <key> [--snapshot-after] browser select <selector> <value> [--snapshot-after] browser scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after] + browser screenshot [--out <path>] [--json] browser get <url|title|text|html|value|attr|count|box|styles> [...] browser is <visible|enabled|checked> <selector> browser find <role|text|label|placeholder|alt|title|testid|first|last|nth> ... @@ -4776,16 +8514,7 @@ struct CMUXCLI { browser addinitscript <script> browser addscript <script> browser addstyle <css> - browser viewport <width> <height> (returns not_supported on WKWebView) - browser geolocation|geo <lat> <lon> (returns not_supported on WKWebView) - browser offline <true|false> (returns not_supported on WKWebView) - browser trace <start|stop> [path] (returns not_supported on WKWebView) - browser network <route|unroute|requests> [...] (returns not_supported on WKWebView) - browser screencast <start|stop> (returns not_supported on WKWebView) - browser input <mouse|keyboard|touch> (returns not_supported on WKWebView) browser identify [--surface <id|ref|index>] - - (legacy browser aliases still supported: open-browser, navigate, browser-back, browser-forward, browser-reload, get-url) help Environment: @@ -4793,7 +8522,8 @@ struct CMUXCLI { ALL commands (send, list-panels, new-split, notify, etc.). CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. - CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock). + CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults + to /tmp/cmux.sock and auto-discovers tagged/debug sockets. """ } } @@ -4801,6 +8531,8 @@ struct CMUXCLI { @main struct CMUXTermMain { static func main() { + // CLI tools should ignore SIGPIPE so closed stdout pipes do not terminate the process. + _ = signal(SIGPIPE, SIG_IGN) let cli = CMUXCLI(args: CommandLine.arguments) do { try cli.run() diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 65cc12e6..f39108b8 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -22,11 +22,16 @@ 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 */; }; + A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.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 */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; + A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; + A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; + A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; @@ -34,10 +39,14 @@ A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; A5001250 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; + B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; + A5008371 /* BrowserSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008370 /* BrowserSearchOverlay.swift */; }; + A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; + A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; }; A5001521 /* PostHogAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001520 /* PostHogAnalytics.swift */; }; A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; }; A5001202 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001212 /* UpdateDelegate.swift */; }; @@ -54,28 +63,42 @@ 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 */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; + B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; + B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; - F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; - F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; - F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; - /* End PBXBuildFile section */ + F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; + F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; + F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; + F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; + F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; + F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; + F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; + F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; + A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; + A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; + DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; + DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; + A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; + /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ A5001020 /* Embed Frameworks */ = { @@ -96,6 +119,7 @@ files = ( B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */, + D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */, ); name = "Copy CLI"; runOnlyForDeploymentPostprocessing = 0; @@ -144,6 +168,8 @@ 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>"; }; + A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.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>"; }; @@ -154,14 +180,19 @@ A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; }; A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = "<group>"; }; + A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = "<group>"; }; + A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = "<group>"; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = "<group>"; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = "<group>"; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = "<group>"; }; A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; }; + A5008370 /* BrowserSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserSearchOverlay.swift; sourceTree = "<group>"; }; + A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = "<group>"; }; A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; }; A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = "<group>"; }; + A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; }; A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = "<group>"; }; A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = "<group>"; }; A5001213 /* UpdateDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDriver.swift; sourceTree = "<group>"; }; @@ -177,11 +208,15 @@ 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>"; }; + B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.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>"; }; + IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; 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 +225,25 @@ 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 */ + B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.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>"; }; + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; }; + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; }; + F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; }; + F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; }; + F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; }; + A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; }; + A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; }; + DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; + DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; }; + A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = "<group>"; }; +/* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ A5001030 /* Frameworks */ = { @@ -208,6 +254,7 @@ A5001230 /* Sparkle in Frameworks */, A5001250 /* Sentry in Frameworks */, A5001270 /* PostHog in Frameworks */, + A5001290 /* MarkdownUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -229,6 +276,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -242,6 +290,9 @@ A5001100 /* Assets.xcassets in Resources */, 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */, A5002000 /* THIRD_PARTY_LICENSES.md in Resources */, + DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */, + DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */, + A5001623 /* cmux.sdef in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -289,6 +340,7 @@ B9000003A1B2C3D4E5F60719 /* CLI */, 087C454FFF74443AB06942C3 /* Resources */, A5001101 /* Assets.xcassets */, + IC000002 /* AppIcon.icon */, A5001016 /* GhosttyKit.xcframework */, A5001017 /* ghostty.h */, A5001018 /* cmux-Bridging-Header.h */, @@ -307,6 +359,7 @@ B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, + A50012F4 /* KeyboardLayout.swift */, A5001013 /* TabManager.swift */, A5001511 /* UITestRecorder.swift */, A5001520 /* PostHogAnalytics.swift */, @@ -319,15 +372,21 @@ A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, + A5001600 /* SentryHelper.swift */, + A5001620 /* AppleScriptSupport.swift */, A5001090 /* AppDelegate.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, A5001301 /* SurfaceSearchOverlay.swift */, + A5008370 /* BrowserSearchOverlay.swift */, + A5008372 /* BrowserFindJavaScript.swift */, A5001410 /* Panel.swift */, A5001411 /* TerminalPanel.swift */, A5001412 /* BrowserPanel.swift */, A5001413 /* TerminalPanelView.swift */, A5001414 /* BrowserPanelView.swift */, + A5001418 /* MarkdownPanel.swift */, + A5001419 /* MarkdownPanelView.swift */, A5001510 /* CmuxWebView.swift */, A5001415 /* PanelContentView.swift */, A5001211 /* UpdateController.swift */, @@ -345,6 +404,7 @@ A5001219 /* WindowToolbarController.swift */, A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, + A5001611 /* SessionPersistence.swift */, ); path = Sources; sourceTree = "<group>"; @@ -363,6 +423,9 @@ B2E7294509CC42FE9191870E /* xterm-ghostty */, A5002001 /* THIRD_PARTY_LICENSES.md */, C1ADE00001A1B2C3D4E5F719 /* claude */, + DA7A10CA710E000000000001 /* Localizable.xcstrings */, + DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, + A5001622 /* cmux.sdef */, ); path = Resources; sourceTree = "<group>"; @@ -385,8 +448,10 @@ B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */, B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */, B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, + B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */, B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, + B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, @@ -395,17 +460,24 @@ path = cmuxUITests; sourceTree = "<group>"; }; - F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { - isa = PBXGroup; - children = ( - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, - F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, - F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, - F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, - ); - path = cmuxTests; - sourceTree = "<group>"; - }; + F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { + isa = PBXGroup; + children = ( + F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, + F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, + F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, + F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, + F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, + F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, + A5008380 /* BrowserFindJavaScriptTests.swift */, + A5008382 /* CommandPaletteSearchEngineTests.swift */, + ); + path = cmuxTests; + sourceTree = "<group>"; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -430,6 +502,7 @@ A5001251 /* Sentry */, A5001271 /* PostHog */, A5001261 /* Bonsplit */, + A5001291 /* MarkdownUI */, ); name = GhosttyTabs; productName = GhosttyTabs; @@ -447,6 +520,9 @@ ); dependencies = ( ); + packageProductDependencies = ( + A5001251 /* Sentry */, + ); name = "cmux-cli"; productName = cmux; productReference = B9000004A1B2C3D4E5F60719 /* cmux */; @@ -505,12 +581,30 @@ knownRegions = ( en, Base, + ja, + ar, + bs, + da, + de, + es, + fr, + it, + ko, + nb, + pl, + pt-BR, + ru, + th, + tr, + zh-Hans, + zh-Hant, ); mainGroup = A5001040; packageReferences = ( A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */, A5001252 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, A5001272 /* XCRemoteSwiftPackageReference "posthog-ios" */, + A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, A5001260 /* XCLocalSwiftPackageReference "bonsplit" */, ); productRefGroup = A5001042 /* Products */; @@ -536,6 +630,7 @@ B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, + A50012F5 /* KeyboardLayout.swift in Sources */, A5001003 /* TabManager.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, A5001521 /* PostHogAnalytics.swift in Sources */, @@ -548,15 +643,21 @@ A5001007 /* TerminalController.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, + A5001601 /* SentryHelper.swift in Sources */, + A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, + A5008371 /* BrowserSearchOverlay.swift in Sources */, + A5008373 /* BrowserFindJavaScript.swift in Sources */, A5001400 /* Panel.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, A5001402 /* BrowserPanel.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, + A5001420 /* MarkdownPanel.swift in Sources */, + A5001421 /* MarkdownPanelView.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, A5001405 /* PanelContentView.swift in Sources */, A5001201 /* UpdateController.swift in Sources */, @@ -574,6 +675,7 @@ A5001209 /* WindowToolbarController.swift in Sources */, A5001240 /* WindowDecorationsController.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, + A5001610 /* SessionPersistence.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -585,8 +687,10 @@ B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */, B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */, B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, + B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */, B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, + B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, @@ -594,18 +698,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F1000005A1B2C3D4E5F60718 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, - F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, - F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, - F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B9000006A1B2C3D4E5F60719 /* Sources */ = { + F1000005A1B2C3D4E5F60718 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, + F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, + F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, + F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, + F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, + F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, + F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, + F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, + A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, + A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B9000006A1B2C3D4E5F60719 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -689,6 +800,7 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -702,7 +814,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -711,7 +823,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.60.0; + MARKETING_VERSION = 0.62.1; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -741,7 +853,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -750,7 +862,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.60.0; + MARKETING_VERSION = 0.62.1; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -764,7 +876,7 @@ "-framework", Carbon, ); - ONLY_ACTIVE_ARCH = YES; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app; PRODUCT_NAME = cmux; SPARKLE_PUBLIC_KEY = "avjcgKibf1FTvhIjLBxhd+0HSpsXU4D0IGlVk8cgqRc="; @@ -778,6 +890,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; PRODUCT_MODULE_NAME = cmux_cli; @@ -791,9 +909,16 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; PRODUCT_MODULE_NAME = cmux_cli; + ONLY_ACTIVE_ARCH = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; @@ -804,10 +929,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 76; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.60.0; + MARKETING_VERSION = 0.62.1; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -821,11 +946,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 76; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.60.0; - ONLY_ACTIVE_ARCH = YES; + MARKETING_VERSION = 0.62.1; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -838,10 +963,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 76; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.60.0; + MARKETING_VERSION = 0.62.1; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -857,11 +982,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 76; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.60.0; - ONLY_ACTIVE_ARCH = YES; + MARKETING_VERSION = 0.62.1; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -897,6 +1022,14 @@ minimumVersion = 3.41.0; }; }; + A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; + }; + }; A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = { isa = XCLocalSwiftPackageReference; relativePath = vendor/bonsplit; @@ -924,6 +1057,11 @@ package = A5001260 /* XCLocalSwiftPackageReference "bonsplit" */; productName = Bonsplit; }; + A5001291 /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCConfigurationList section */ diff --git a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 492ab4e9..3bf056ae 100644 --- a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "a1df212ee81645b29368e6cc39c83aebbbafb5c592f726afc990bab228304987", + "originHash" : "b66d812c506be67c70b46c63421ab2eb2db013613c74252ad1205f662ada079b", "pins" : [ + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, { "identity" : "posthog-ios", "kind" : "remoteSourceControl", @@ -27,6 +36,24 @@ "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", "version" : "2.8.1" } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } } ], "version" : 3 diff --git a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme new file mode 100644 index 00000000..415b3867 --- /dev/null +++ b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme LastUpgradeVersion="1500" version="1.7"> + <BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"> + <BuildActionEntries> + <BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES"> + <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/> + </BuildActionEntry> + <BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO"> + <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"> + <Testables> + <TestableReference skipped="NO"> + <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/> + </TestableReference> + <TestableReference skipped="NO"> + <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/> + </TestableReference> + </Testables> + <MacroExpansion> + <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/> + </MacroExpansion> + </TestAction> + <LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="YES" debugDocumentVersioning="YES" allowLocationSimulation="YES"> + <BuildableProductRunnable runnableDebuggingMode="0"> + <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/> + </BuildableProductRunnable> + </LaunchAction> + <ProfileAction buildConfiguration="Debug" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"> + <BuildableProductRunnable runnableDebuggingMode="0"> + <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/> + </BuildableProductRunnable> + </ProfileAction> + <AnalyzeAction buildConfiguration="Debug"/> + <ArchiveAction buildConfiguration="Debug" revealArchiveInOrganizer="YES"/> +</Scheme> diff --git a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme index 23f45429..c8f698bc 100644 --- a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme +++ b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme @@ -7,7 +7,7 @@ </BuildActionEntry> </BuildActionEntries> </BuildAction> - <TestAction buildConfiguration="Release" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"> + <TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"> <Testables> <TestableReference skipped="NO"> <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/> diff --git a/README.ar.md b/README.ar.md index 0c29c0ed..86dddcd9 100644 --- a/README.ar.md +++ b/README.ar.md @@ -1,9 +1,5 @@ > تمت هذه الترجمة بواسطة Claude. إذا كانت لديك اقتراحات للتحسين، يرجى فتح PR. -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | العربية | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">تطبيق طرفية لنظام macOS مبني على Ghostty مع علامات تبويب عمودية وإشعارات لوكلاء البرمجة بالذكاء الاصطناعي</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="لقطة شاشة cmux" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | العربية | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="لقطة شاشة cmux" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ فيديو توضيحي</a> · <a href="https://cmux.dev/blog/zen-of-cmux">فلسفة cmux</a> </p> ## الميزات -- **علامات تبويب عمودية** — يعرض الشريط الجانبي فرع git ومجلد العمل والمنافذ المستمعة وآخر نص إشعار -- **حلقات الإشعارات** — تحصل الأجزاء على حلقة زرقاء وتضيء علامات التبويب عندما يحتاج وكلاء الذكاء الاصطناعي (Claude Code، OpenCode) انتباهك -- **لوحة الإشعارات** — عرض جميع الإشعارات المعلقة في مكان واحد، والانتقال إلى أحدث إشعار غير مقروء -- **أجزاء مقسمة** — تقسيم أفقي وعمودي -- **متصفح مدمج** — قسّم متصفحاً بجانب الطرفية مع API قابل للبرمجة مأخوذ من [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>حلقات الإشعارات</h3> +تحصل الأجزاء على حلقة زرقاء وتضيء علامات التبويب عندما يحتاج وكلاء البرمجة انتباهك +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="حلقات الإشعارات" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>لوحة الإشعارات</h3> +عرض جميع الإشعارات المعلقة في مكان واحد، والانتقال إلى أحدث إشعار غير مقروء +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="شارة إشعارات الشريط الجانبي" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>متصفح مدمج</h3> +قسّم متصفحًا بجانب الطرفية مع API قابل للبرمجة مأخوذ من <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="المتصفح المدمج" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>علامات تبويب عمودية + أفقية</h3> +يعرض الشريط الجانبي فرع git وحالة/رقم طلب السحب المرتبط ومجلد العمل والمنافذ المستمعة وآخر نص إشعار. تقسيم أفقي وعمودي. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="علامات تبويب عمودية وأجزاء مقسمة" width="100%" /> +</td> +</tr> +</table> + - **قابل للبرمجة** — CLI وsocket API لإنشاء مساحات العمل وتقسيم الأجزاء وإرسال ضغطات المفاتيح وأتمتة المتصفح - **تطبيق macOS أصلي** — مبني بـ Swift وAppKit، وليس Electron. بدء تشغيل سريع واستهلاك ذاكرة منخفض. - **متوافق مع Ghostty** — يقرأ إعداداتك الحالية من `~/.config/ghostty/config` للسمات والخطوط والألوان @@ -37,7 +80,7 @@ <img src="./docs/assets/macos-badge.png" alt="تحميل cmux لنظام macOS" width="180" /> </a> -افتح ملف `.dmg` واسحب cmux إلى مجلد التطبيقات. يتم تحديث cmux تلقائياً عبر Sparkle، لذا تحتاج للتحميل مرة واحدة فقط. +افتح ملف `.dmg` واسحب cmux إلى مجلد التطبيقات. يتم تحديث cmux تلقائيًا عبر Sparkle، لذا تحتاج للتحميل مرة واحدة فقط. ### Homebrew @@ -46,7 +89,7 @@ brew tap manaflow-ai/cmux brew install --cask cmux ``` -للتحديث لاحقاً: +للتحديث لاحقًا: ```bash brew upgrade --cask cmux @@ -56,16 +99,30 @@ brew upgrade --cask cmux ## لماذا cmux؟ -أقوم بتشغيل الكثير من جلسات Claude Code وCodex بالتوازي. كنت أستخدم Ghostty مع مجموعة من الأجزاء المقسمة، وأعتمد على إشعارات macOS الأصلية لمعرفة متى يحتاجني وكيل ما. لكن نص إشعار Claude Code يكون دائماً مجرد "Claude is waiting for your input" بدون أي سياق، ومع فتح عدد كافٍ من علامات التبويب لم أعد قادراً حتى على قراءة العناوين. +أقوم بتشغيل الكثير من جلسات Claude Code وCodex بالتوازي. كنت أستخدم Ghostty مع مجموعة من الأجزاء المقسمة، وأعتمد على إشعارات macOS الأصلية لمعرفة متى يحتاجني وكيل ما. لكن نص إشعار Claude Code يكون دائمًا مجرد "Claude is waiting for your input" بدون أي سياق، ومع فتح عدد كافٍ من علامات التبويب لم أعد قادرًا حتى على قراءة العناوين. جربت بعض منظمات البرمجة لكن معظمها كانت تطبيقات Electron/Tauri وأداؤها كان يزعجني. كما أنني أفضل الطرفية لأن منظمات GUI تحبسك في سير عملها. لذا بنيت cmux كتطبيق macOS أصلي بـ Swift/AppKit. يستخدم libghostty لعرض الطرفية ويقرأ إعدادات Ghostty الحالية للسمات والخطوط والألوان. -الإضافات الرئيسية هي الشريط الجانبي ونظام الإشعارات. يحتوي الشريط الجانبي على علامات تبويب عمودية تعرض فرع git ومجلد العمل والمنافذ المستمعة وآخر نص إشعار لكل مساحة عمل. يلتقط نظام الإشعارات تسلسلات الطرفية (OSC 9/99/777) ولديه CLI (`cmux notify`) يمكنك ربطه بخطافات الوكلاء لـ Claude Code وOpenCode وغيرها. عندما ينتظر وكيل ما، يحصل جزؤه على حلقة زرقاء وتضيء علامة التبويب في الشريط الجانبي، حتى أتمكن من معرفة أيها يحتاجني عبر الأقسام وعلامات التبويب. Cmd+Shift+U ينتقل إلى أحدث إشعار غير مقروء. +الإضافات الرئيسية هي الشريط الجانبي ونظام الإشعارات. يحتوي الشريط الجانبي على علامات تبويب عمودية تعرض فرع git وحالة/رقم طلب السحب المرتبط ومجلد العمل والمنافذ المستمعة وآخر نص إشعار لكل مساحة عمل. يلتقط نظام الإشعارات تسلسلات الطرفية (OSC 9/99/777) ولديه CLI (`cmux notify`) يمكنك ربطه بخطافات الوكلاء لـ Claude Code وOpenCode وغيرها. عندما ينتظر وكيل ما، يحصل جزؤه على حلقة زرقاء وتضيء علامة التبويب في الشريط الجانبي، حتى أتمكن من معرفة أيها يحتاجني عبر الأقسام وعلامات التبويب. Cmd+Shift+U ينتقل إلى أحدث إشعار غير مقروء. المتصفح المدمج لديه API قابل للبرمجة مأخوذ من [agent-browser](https://github.com/vercel-labs/agent-browser). يمكن للوكلاء التقاط شجرة إمكانية الوصول والحصول على مراجع العناصر والنقر وملء النماذج وتنفيذ JS. يمكنك تقسيم جزء متصفح بجانب الطرفية وجعل Claude Code يتفاعل مع خادم التطوير مباشرة. كل شيء قابل للبرمجة عبر CLI وsocket API — إنشاء مساحات العمل/علامات التبويب، تقسيم الأجزاء، إرسال ضغطات المفاتيح، فتح عناوين URL في المتصفح. +## فلسفة cmux + +cmux لا يفرض على المطورين طريقة استخدام أدواتهم. إنه طرفية ومتصفح مع واجهة سطر أوامر، والباقي متروك لك. + +cmux هو لبنة أساسية وليس حلًا جاهزًا. يمنحك طرفية ومتصفحًا وإشعارات ومساحات عمل وأقسامًا وعلامات تبويب وواجهة سطر أوامر للتحكم في كل ذلك. cmux لا يجبرك على طريقة محددة لاستخدام وكلاء البرمجة. ما تبنيه باستخدام هذه اللبنات الأساسية هو ملكك. + +أفضل المطورين دائمًا ما بنوا أدواتهم الخاصة. لم يكتشف أحد بعد أفضل طريقة للعمل مع الوكلاء، والفرق التي تبني منتجات مغلقة لم تكتشفها أيضًا بالتأكيد. المطورون الأقرب لقواعد بياناتهم الخاصة سيكتشفونها أولًا. + +أعطِ مليون مطور لبنات أساسية قابلة للتركيب وسيجدون بشكل جماعي أكثر سير العمل كفاءة أسرع مما يمكن لأي فريق منتج تصميمه من الأعلى إلى الأسفل. + +## التوثيق + +لمزيد من المعلومات حول كيفية إعداد cmux، [توجه إلى وثائقنا](https://cmux.dev/docs/getting-started?utm_source=readme). + ## اختصارات لوحة المفاتيح ### مساحات العمل @@ -78,6 +135,7 @@ brew upgrade --cask cmux | ⌃ ⌘ ] | مساحة العمل التالية | | ⌃ ⌘ [ | مساحة العمل السابقة | | ⌘ ⇧ W | إغلاق مساحة العمل | +| ⌘ ⇧ R | إعادة تسمية مساحة العمل | | ⌘ B | تبديل الشريط الجانبي | ### الأسطح @@ -104,6 +162,8 @@ brew upgrade --cask cmux ### المتصفح +اختصارات أدوات المطور في المتصفح تتبع إعدادات Safari الافتراضية ويمكن تخصيصها في `الإعدادات ← اختصارات لوحة المفاتيح`. + | الاختصار | الإجراء | |----------|--------| | ⌘ ⇧ L | فتح المتصفح في قسم | @@ -111,7 +171,8 @@ brew upgrade --cask cmux | ⌘ [ | للخلف | | ⌘ ] | للأمام | | ⌘ R | إعادة تحميل الصفحة | -| ⌥ ⌘ I | فتح أدوات المطور | +| ⌥ ⌘ I | تبديل أدوات المطور (إعداد Safari الافتراضي) | +| ⌥ ⌘ C | عرض وحدة تحكم JavaScript (إعداد Safari الافتراضي) | ### الإشعارات @@ -148,6 +209,63 @@ brew upgrade --cask cmux | ⌘ ⇧ , | إعادة تحميل الإعدادات | | ⌘ Q | إنهاء | +## الإصدارات الليلية + +[تحميل cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY هو تطبيق منفصل بمعرّف حزمة خاص به، لذا يعمل بجانب الإصدار المستقر. يُبنى تلقائيًا من أحدث commit على فرع `main` ويتم تحديثه تلقائيًا عبر Sparkle الخاص به. + +## استعادة الجلسة (السلوك الحالي) + +عند إعادة التشغيل، يستعيد cmux حاليًا تخطيط التطبيق والبيانات الوصفية فقط: +- تخطيط النوافذ/مساحات العمل/الأجزاء +- مجلدات العمل +- سجل تمرير الطرفية (أفضل جهد) +- عنوان URL للمتصفح وسجل التنقل + +cmux **لا** يستعيد حالة العمليات الحية داخل تطبيقات الطرفية. على سبيل المثال، جلسات Claude Code/tmux/vim النشطة لا يتم استئنافها بعد إعادة التشغيل بعد. + +## تاريخ النجوم + +<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> + +## المساهمة + +طرق للمشاركة: + +- تابعنا على X للتحديثات [@manaflowai](https://x.com/manaflowai)، [@lawrencecchen](https://x.com/lawrencecchen)، و[@austinywang](https://x.com/austinywang) +- انضم إلى المحادثة على [Discord](https://discord.gg/xsgFEVrWCZ) +- أنشئ وشارك في [قضايا GitHub](https://github.com/manaflow-ai/cmux/issues) و[المناقشات](https://github.com/manaflow-ai/cmux/discussions) +- أخبرنا بما تبنيه باستخدام cmux + +## المجتمع + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## إصدار المؤسسين + +cmux مجاني ومفتوح المصدر وسيظل كذلك دائمًا. إذا كنت ترغب في دعم التطوير والحصول على وصول مبكر لما هو قادم: + +**[احصل على إصدار المؤسسين](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **أولوية لطلبات الميزات/إصلاح الأخطاء** +- **وصول مبكر: ذكاء اصطناعي لـ cmux يمنحك سياقًا عن كل مساحة عمل وعلامة تبويب ولوحة** +- **وصول مبكر: تطبيق iOS مع مزامنة الطرفيات بين سطح المكتب والهاتف** +- **وصول مبكر: أجهزة افتراضية سحابية** +- **وصول مبكر: وضع الصوت** +- **iMessage/WhatsApp الشخصي الخاص بي** + ## الرخصة هذا المشروع مرخص بموجب رخصة GNU Affero العامة الإصدار 3.0 أو أحدث (`AGPL-3.0-or-later`). diff --git a/README.bs.md b/README.bs.md index ef113fe0..6782fa3a 100644 --- a/README.bs.md +++ b/README.bs.md @@ -1,9 +1,5 @@ > Ovaj prijevod je generisan od strane Claude. Ako imate prijedloge za poboljšanje, otvorite PR. -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | Bosanski | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">macOS terminal baziran na Ghostty sa vertikalnim tabovima i obavještenjima za AI agente za programiranje</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="cmux snimak ekrana" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | Bosanski | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmux snimak ekrana" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo video</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Funkcije -- **Vertikalni tabovi** — Bočna traka prikazuje git granu, radni direktorij, portove koji slušaju i tekst posljednjeg obavještenja -- **Prstenovi obavještenja** — Paneli dobijaju plavi prsten, a tabovi se osvjetljavaju kada AI agenti (Claude Code, OpenCode) trebaju vašu pažnju -- **Panel obavještenja** — Pregledajte sva obavještenja na čekanju na jednom mjestu, skočite na najnovije nepročitano -- **Podijeljeni paneli** — Horizontalna i vertikalna podjela -- **Ugrađeni preglednik** — Podijelite preglednik pored terminala sa skriptabilnim API portiranim iz [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Prstenovi obavještenja</h3> +Paneli dobijaju plavi prsten, a tabovi se osvjetljavaju kada agenti za programiranje trebaju vašu pažnju +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Prstenovi obavještenja" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Panel obavještenja</h3> +Pregledajte sva obavještenja na čekanju na jednom mjestu, skočite na najnovije nepročitano +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Značka obavještenja u bočnoj traci" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Ugrađeni preglednik</h3> +Podijelite preglednik pored terminala sa skriptabilnim API portiranim iz <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Ugrađeni preglednik" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Vertikalni + horizontalni tabovi</h3> +Bočna traka prikazuje git granu, status/broj povezanog PR-a, radni direktorij, portove koji slušaju i tekst posljednjeg obavještenja. Horizontalna i vertikalna podjela. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertikalni tabovi i podijeljeni paneli" width="100%" /> +</td> +</tr> +</table> + - **Skriptabilan** — CLI i socket API za kreiranje radnih prostora, dijeljenje panela, slanje pritisaka tipki i automatizaciju preglednika - **Nativna macOS aplikacija** — Izgrađena sa Swift i AppKit, ne Electron. Brzo pokretanje, niska potrošnja memorije. - **Kompatibilan sa Ghostty** — Čita vašu postojeću konfiguraciju `~/.config/ghostty/config` za teme, fontove i boje @@ -60,12 +103,26 @@ Pokrećem mnogo Claude Code i Codex sesija paralelno. Koristio sam Ghostty sa go Isprobao sam nekoliko orkestratora za kodiranje, ali većina ih je bila Electron/Tauri aplikacije i performanse su me nervirale. Također jednostavno preferiram terminal jer GUI orkestratori vas zaključavaju u svoj radni tok. Zato sam izgradio cmux kao nativnu macOS aplikaciju u Swift/AppKit. Koristi libghostty za renderiranje terminala i čita vašu postojeću Ghostty konfiguraciju za teme, fontove i boje. -Glavni dodaci su bočna traka i sistem obavještenja. Bočna traka ima vertikalne tabove koji prikazuju git granu, radni direktorij, portove koji slušaju i tekst posljednjeg obavještenja za svaki radni prostor. Sistem obavještenja hvata terminalne sekvence (OSC 9/99/777) i ima CLI (`cmux notify`) koji možete povezati sa hookovima agenata za Claude Code, OpenCode itd. Kada agent čeka, njegov panel dobija plavi prsten, a tab se osvjetljava u bočnoj traci, tako da mogu vidjeti koji me treba kroz podjele i tabove. Cmd+Shift+U skače na najnovije nepročitano. +Glavni dodaci su bočna traka i sistem obavještenja. Bočna traka ima vertikalne tabove koji prikazuju git granu, status/broj povezanog PR-a, radni direktorij, portove koji slušaju i tekst posljednjeg obavještenja za svaki radni prostor. Sistem obavještenja hvata terminalne sekvence (OSC 9/99/777) i ima CLI (`cmux notify`) koji možete povezati sa hookovima agenata za Claude Code, OpenCode itd. Kada agent čeka, njegov panel dobija plavi prsten, a tab se osvjetljava u bočnoj traci, tako da mogu vidjeti koji me treba kroz podjele i tabove. Cmd+Shift+U skače na najnovije nepročitano. Ugrađeni preglednik ima skriptabilni API portiran iz [agent-browser](https://github.com/vercel-labs/agent-browser). Agenti mogu snimiti stablo pristupačnosti, dobiti reference elemenata, kliknuti, popuniti formulare i evaluirati JS. Možete podijeliti panel preglednika pored terminala i omogućiti Claude Code da direktno komunicira sa vašim razvojnim serverom. Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova, dijeljenje panela, slanje pritisaka tipki, otvaranje URL-ova u pregledniku. +## The Zen of cmux + +cmux ne propisuje programerima kako da koriste svoje alate. To je terminal i preglednik sa CLI-jem, a ostatak je na vama. + +cmux je primitiv, ne rješenje. Daje vam terminal, preglednik, obavještenja, radne prostore, podjele, tabove i CLI za kontrolu svega toga. cmux vas ne prisiljava na određeni način korištenja agenata za kodiranje. Ono što izgradite sa tim primitivima je vaše. + +Najbolji programeri su oduvijek gradili vlastite alate. Niko još nije otkrio najbolji način rada sa agentima, a timovi koji grade zatvorene proizvode to također nisu uradili. Programeri koji su najbliži svojim bazama koda će to otkriti prvi. + +Dajte milion programera kompozabilne primitive i oni će kolektivno pronaći najefikasnije tokove rada brže nego što bi bilo koji produktni tim mogao dizajnirati odozgo prema dolje. + +## Dokumentacija + +Za više informacija o konfiguraciji cmux, posjetite [našu dokumentaciju](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Prečice na Tastaturi ### Radni prostori @@ -78,6 +135,7 @@ Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova, | ⌃ ⌘ ] | Sljedeći radni prostor | | ⌃ ⌘ [ | Prethodni radni prostor | | ⌘ ⇧ W | Zatvori radni prostor | +| ⌘ ⇧ R | Preimenuj radni prostor | | ⌘ B | Prikaži/sakrij bočnu traku | ### Površine @@ -104,6 +162,8 @@ Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova, ### Preglednik +Prečice razvojnih alata preglednika prate Safari zadane postavke i mogu se prilagoditi u `Postavke → Prečice na tastaturi`. + | Prečica | Akcija | |----------|--------| | ⌘ ⇧ L | Otvori preglednik u podjeli | @@ -111,7 +171,8 @@ Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova, | ⌘ [ | Nazad | | ⌘ ] | Naprijed | | ⌘ R | Ponovo učitaj stranicu | -| ⌥ ⌘ I | Otvori Alate za Programere | +| ⌥ ⌘ I | Prikaži/sakrij Alate za Programere (Safari zadano) | +| ⌥ ⌘ C | Prikaži JavaScript Konzolu (Safari zadano) | ### Obavještenja @@ -148,6 +209,63 @@ Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova, | ⌘ ⇧ , | Ponovo učitaj konfiguraciju | | ⌘ Q | Zatvori | +## Noćne verzije + +[Preuzmi cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY je zasebna aplikacija sa vlastitim bundle ID-om, tako da radi uporedo sa stabilnom verzijom. Automatski se gradi iz najnovijeg `main` commita i ažurira se putem vlastitog Sparkle feeda. + +## Vraćanje sesije (trenutno ponašanje) + +Prilikom ponovnog pokretanja, cmux trenutno vraća samo raspored aplikacije i metapodatke: +- Raspored prozora/radnih prostora/panela +- Radne direktorije +- Scrollback terminala (po mogućnosti) +- URL preglednika i historija navigacije + +cmux **ne** vraća stanje živih procesa unutar terminalnih aplikacija. Na primjer, aktivne sesije Claude Code/tmux/vim se još ne nastavljaju nakon restarta. + +## Historija zvjezdica + +<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> + +## Doprinos + +Načini da se uključite: + +- Pratite nas na X za ažuriranja [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) i [@austinywang](https://x.com/austinywang) +- Pridružite se razgovoru na [Discordu](https://discord.gg/xsgFEVrWCZ) +- Kreirajte i učestvujte u [GitHub issues](https://github.com/manaflow-ai/cmux/issues) i [diskusijama](https://github.com/manaflow-ai/cmux/discussions) +- Javite nam šta gradite sa cmux + +## Zajednica + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Osnivačko izdanje + +cmux je besplatan, otvorenog koda i uvijek će biti. Ako želite podržati razvoj i dobiti rani pristup onome što dolazi: + +**[Nabavite Osnivačko izdanje](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Prioritetni zahtjevi za funkcije/ispravke grešaka** +- **Rani pristup: cmux AI koji vam daje kontekst o svakom radnom prostoru, tabu i panelu** +- **Rani pristup: iOS aplikacija sa terminalima sinhroniziranim između desktopa i telefona** +- **Rani pristup: Cloud VM-ovi** +- **Rani pristup: Glasovni režim** +- **Moj lični iMessage/WhatsApp** + ## Licenca Ovaj projekat je licenciran pod GNU Affero General Public License v3.0 ili novijom (`AGPL-3.0-or-later`). diff --git a/README.da.md b/README.da.md index 4012df15..db36c1df 100644 --- a/README.da.md +++ b/README.da.md @@ -1,9 +1,5 @@ > Denne oversættelse er genereret af Claude. Har du forslag til forbedringer, er du velkommen til at oprette en PR. -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | Dansk | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">En Ghostty-baseret macOS-terminal med lodrette faner og notifikationer til AI-kodningsagenter</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="cmux skærmbillede" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | Dansk | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmux skærmbillede" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demovideo</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Funktioner -- **Lodrette faner** — Sidebjælken viser git-branch, arbejdsmappe, lyttende porte og seneste notifikationstekst -- **Notifikationsringe** — Paneler får en blå ring, og faner lyser op, når AI-agenter (Claude Code, OpenCode) har brug for din opmærksomhed -- **Notifikationspanel** — Se alle ventende notifikationer ét sted, hop til den seneste ulæste -- **Delte paneler** — Vandrette og lodrette opdelinger -- **Indbygget browser** — Del en browser ved siden af din terminal med en scriptbar API porteret fra [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Notifikationsringe</h3> +Paneler får en blå ring, og faner lyser op, når kodningsagenter har brug for din opmærksomhed +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Notifikationsringe" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Notifikationspanel</h3> +Se alle ventende notifikationer ét sted, hop til den seneste ulæste +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Notifikationsbadge i sidebjælken" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Indbygget browser</h3> +Del en browser ved siden af din terminal med en scriptbar API porteret fra <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Indbygget browser" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Lodrette + vandrette faner</h3> +Sidebjælken viser git-branch, tilknyttet PR-status/nummer, arbejdsmappe, lyttende porte og seneste notifikationstekst. Del vandret og lodret. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Lodrette faner og delte paneler" width="100%" /> +</td> +</tr> +</table> + - **Scriptbar** — CLI og socket API til at oprette workspaces, dele paneler, sende tastetryk og automatisere browseren - **Nativ macOS-app** — Bygget med Swift og AppKit, ikke Electron. Hurtig opstart, lavt hukommelsesforbrug. - **Ghostty-kompatibel** — Læser din eksisterende `~/.config/ghostty/config` til temaer, skrifttyper og farver @@ -60,12 +103,26 @@ Jeg kører mange Claude Code- og Codex-sessioner parallelt. Jeg brugte Ghostty m Jeg prøvede et par kodningsorkestratore, men de fleste var Electron/Tauri-apps, og ydelsen irriterede mig. Jeg foretrækker også bare terminalen, da GUI-orkestratore låser dig ind i deres arbejdsgang. Så jeg byggede cmux som en nativ macOS-app i Swift/AppKit. Den bruger libghostty til terminal-rendering og læser din eksisterende Ghostty-konfiguration til temaer, skrifttyper og farver. -De vigtigste tilføjelser er sidebjælken og notifikationssystemet. Sidebjælken har lodrette faner, der viser git-branch, arbejdsmappe, lyttende porte og den seneste notifikationstekst for hvert workspace. Notifikationssystemet opfanger terminalsekvenser (OSC 9/99/777) og har en CLI (`cmux notify`), du kan koble til agent-hooks for Claude Code, OpenCode osv. Når en agent venter, får dens panel en blå ring, og fanen lyser op i sidebjælken, så jeg kan se, hvilken der har brug for mig på tværs af opdelinger og faner. Cmd+Shift+U hopper til den seneste ulæste. +De vigtigste tilføjelser er sidebjælken og notifikationssystemet. Sidebjælken har lodrette faner, der viser git-branch, tilknyttet PR-status/nummer, arbejdsmappe, lyttende porte og den seneste notifikationstekst for hvert workspace. Notifikationssystemet opfanger terminalsekvenser (OSC 9/99/777) og har en CLI (`cmux notify`), du kan koble til agent-hooks for Claude Code, OpenCode osv. Når en agent venter, får dens panel en blå ring, og fanen lyser op i sidebjælken, så jeg kan se, hvilken der har brug for mig på tværs af opdelinger og faner. Cmd+Shift+U hopper til den seneste ulæste. Den indbyggede browser har en scriptbar API porteret fra [agent-browser](https://github.com/vercel-labs/agent-browser). Agenter kan tage et snapshot af tilgængelighedstræet, få elementreferencer, klikke, udfylde formularer og evaluere JS. Du kan dele et browserpanel ved siden af din terminal og lade Claude Code interagere direkte med din udviklingsserver. Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del paneler, send tastetryk, åbn URL'er i browseren. +## The Zen of cmux + +cmux foreskriver ikke, hvordan udviklere bruger deres værktøjer. Det er en terminal og browser med en CLI, resten er op til dig. + +cmux er en primitiv, ikke en løsning. Det giver dig en terminal, en browser, notifikationer, workspaces, opdelinger, faner og en CLI til at styre det hele. cmux tvinger dig ikke ind i en forudbestemt måde at bruge kodningsagenter på. Hvad du bygger med primitiverne, er dit eget. + +De bedste udviklere har altid bygget deres egne værktøjer. Ingen har endnu fundet den bedste måde at arbejde med agenter på, og holdene bag lukkede produkter har heller ikke. De udviklere, der er tættest på deres egne kodebaser, vil finde ud af det først. + +Giv en million udviklere komponerbare primitiver, og de vil kollektivt finde de mest effektive arbejdsgange hurtigere, end noget produkthold kunne designe oppefra. + +## Dokumentation + +For mere information om konfiguration af cmux, [se vores dokumentation](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Tastaturgenveje ### Workspaces @@ -78,6 +135,7 @@ Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del panel | ⌃ ⌘ ] | Næste workspace | | ⌃ ⌘ [ | Forrige workspace | | ⌘ ⇧ W | Luk workspace | +| ⌘ ⇧ R | Omdøb workspace | | ⌘ B | Skjul/vis sidebjælke | ### Overflader @@ -104,6 +162,8 @@ Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del panel ### Browser +Browserens udviklerværktøjsgenveje følger Safaris standarder og kan tilpasses i `Indstillinger → Tastaturgenveje`. + | Genvej | Handling | |----------|--------| | ⌘ ⇧ L | Åbn browser i opdeling | @@ -111,7 +171,8 @@ Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del panel | ⌘ [ | Tilbage | | ⌘ ] | Frem | | ⌘ R | Genindlæs side | -| ⌥ ⌘ I | Åbn Udviklerværktøjer | +| ⌥ ⌘ I | Slå Udviklerværktøjer til/fra (Safari-standard) | +| ⌥ ⌘ C | Vis JavaScript-konsol (Safari-standard) | ### Notifikationer @@ -148,6 +209,63 @@ Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del panel | ⌘ ⇧ , | Genindlæs konfiguration | | ⌘ Q | Afslut | +## Nightly Builds + +[Download cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY er en separat app med sit eget bundle-ID, så den kører side om side med den stabile version. Bygges automatisk fra det seneste `main`-commit og opdaterer sig selv automatisk via sit eget Sparkle-feed. + +## Sessionsgenoprettelse (nuværende adfærd) + +Ved genstart genopretter cmux i øjeblikket kun app-layout og metadata: +- Vindue/workspace/panel-layout +- Arbejdsmapper +- Terminal-scrollback (best effort) +- Browser-URL og navigationshistorik + +cmux genopretter **ikke** aktive procestilstande i terminalapps. For eksempel genoptages aktive Claude Code/tmux/vim-sessioner endnu ikke efter genstart. + +## Stjernehistorik + +<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> + +## Bidrag + +Måder at deltage: + +- Følg os på X for opdateringer [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) og [@austinywang](https://x.com/austinywang) +- Deltag i samtalen på [Discord](https://discord.gg/xsgFEVrWCZ) +- Opret og deltag i [GitHub issues](https://github.com/manaflow-ai/cmux/issues) og [diskussioner](https://github.com/manaflow-ai/cmux/discussions) +- Fortæl os, hvad du bygger med cmux + +## Fællesskab + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Founder's Edition + +cmux er gratis, open source og vil altid være det. Hvis du gerne vil støtte udviklingen og få tidlig adgang til det, der kommer: + +**[Få Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Prioriterede funktionsønsker og fejlrettelser** +- **Tidlig adgang: cmux AI der giver dig kontekst om hvert workspace, fane og panel** +- **Tidlig adgang: iOS-app med terminaler synkroniseret mellem desktop og telefon** +- **Tidlig adgang: Cloud VM'er** +- **Tidlig adgang: Stemmetilstand** +- **Min personlige iMessage/WhatsApp** + ## Licens Dette projekt er licenseret under GNU Affero General Public License v3.0 eller senere (`AGPL-3.0-or-later`). diff --git a/README.de.md b/README.de.md index 62ee43cd..68bb81ff 100644 --- a/README.de.md +++ b/README.de.md @@ -1,7 +1,5 @@ > Diese Übersetzung wurde von Claude erstellt. Verbesserungsvorschläge sind als PR willkommen. -<p align="center"><a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | Deutsch | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a></p> - <h1 align="center">cmux</h1> <p align="center">Ein Ghostty-basiertes macOS-Terminal mit vertikalen Tabs und Benachrichtigungen für AI-Coding-Agenten</p> @@ -12,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="cmux Screenshot" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | Deutsch | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmux Screenshot" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo-Video</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Funktionen -- **Vertikale Tabs** — Die Seitenleiste zeigt Git-Branch, Arbeitsverzeichnis, lauschende Ports und den neuesten Benachrichtigungstext -- **Benachrichtigungsringe** — Bereiche erhalten einen blauen Ring und Tabs leuchten auf, wenn AI-Agenten (Claude Code, OpenCode) Ihre Aufmerksamkeit benötigen -- **Benachrichtigungspanel** — Alle ausstehenden Benachrichtigungen auf einen Blick sehen und zur neuesten ungelesenen springen -- **Geteilte Bereiche** — Horizontale und vertikale Teilung -- **Integrierter Browser** — Teilen Sie einen Browser neben Ihrem Terminal mit einer skriptfähigen API, portiert von [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Benachrichtigungsringe</h3> +Bereiche erhalten einen blauen Ring und Tabs leuchten auf, wenn Coding-Agenten Ihre Aufmerksamkeit benötigen +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Benachrichtigungsringe" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Benachrichtigungspanel</h3> +Alle ausstehenden Benachrichtigungen auf einen Blick sehen und zur neuesten ungelesenen springen +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Seitenleisten-Benachrichtigungsabzeichen" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Integrierter Browser</h3> +Teilen Sie einen Browser neben Ihrem Terminal mit einer skriptfähigen API, portiert von <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Integrierter Browser" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Vertikale + horizontale Tabs</h3> +Die Seitenleiste zeigt Git-Branch, verknüpften PR-Status/Nummer, Arbeitsverzeichnis, lauschende Ports und den neuesten Benachrichtigungstext. Horizontal und vertikal teilen. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertikale Tabs und geteilte Bereiche" width="100%" /> +</td> +</tr> +</table> + - **Skriptfähig** — CLI und Socket-API zum Erstellen von Arbeitsbereichen, Teilen von Bereichen, Senden von Tastenanschlägen und Automatisieren des Browsers - **Native macOS-App** — Entwickelt mit Swift und AppKit, nicht Electron. Schneller Start, geringer Speicherverbrauch. - **Ghostty-kompatibel** — Liest Ihre vorhandene `~/.config/ghostty/config` für Themes, Schriftarten und Farben @@ -58,12 +103,26 @@ Ich führe viele Claude Code- und Codex-Sitzungen parallel aus. Ich habe Ghostty Ich habe einige Coding-Orchestratoren ausprobiert, aber die meisten waren Electron/Tauri-Apps und die Performance hat mich gestört. Ich bevorzuge außerdem das Terminal, da GUI-Orchestratoren einen in ihren Workflow einschließen. Also habe ich cmux als native macOS-App in Swift/AppKit gebaut. Es verwendet libghostty für das Terminal-Rendering und liest Ihre vorhandene Ghostty-Konfiguration für Themes, Schriftarten und Farben. -Die wesentlichen Ergänzungen sind die Seitenleiste und das Benachrichtigungssystem. Die Seitenleiste hat vertikale Tabs, die Git-Branch, Arbeitsverzeichnis, lauschende Ports und den neuesten Benachrichtigungstext für jeden Arbeitsbereich anzeigen. Das Benachrichtigungssystem erkennt Terminal-Sequenzen (OSC 9/99/777) und bietet eine CLI (`cmux notify`), die Sie in Agent-Hooks für Claude Code, OpenCode usw. einbinden können. Wenn ein Agent wartet, bekommt sein Bereich einen blauen Ring und der Tab leuchtet in der Seitenleiste auf, sodass ich über Teilungen und Tabs hinweg erkennen kann, welcher mich braucht. ⌘⇧U springt zur neuesten ungelesenen Benachrichtigung. +Die wesentlichen Ergänzungen sind die Seitenleiste und das Benachrichtigungssystem. Die Seitenleiste hat vertikale Tabs, die Git-Branch, verknüpften PR-Status/Nummer, Arbeitsverzeichnis, lauschende Ports und den neuesten Benachrichtigungstext für jeden Arbeitsbereich anzeigen. Das Benachrichtigungssystem erkennt Terminal-Sequenzen (OSC 9/99/777) und bietet eine CLI (`cmux notify`), die Sie in Agent-Hooks für Claude Code, OpenCode usw. einbinden können. Wenn ein Agent wartet, bekommt sein Bereich einen blauen Ring und der Tab leuchtet in der Seitenleiste auf, sodass ich über Teilungen und Tabs hinweg erkennen kann, welcher mich braucht. ⌘⇧U springt zur neuesten ungelesenen Benachrichtigung. Der integrierte Browser hat eine skriptfähige API, portiert von [agent-browser](https://github.com/vercel-labs/agent-browser). Agenten können den Barrierefreiheitsbaum erfassen, Elementreferenzen erhalten, klicken, Formulare ausfüllen und JS ausführen. Sie können einen Browser-Bereich neben Ihrem Terminal teilen und Claude Code direkt mit Ihrem Entwicklungsserver interagieren lassen. Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstellen, Bereiche teilen, Tastenanschläge senden, URLs im Browser öffnen. +## The Zen of cmux + +cmux schreibt Entwicklern nicht vor, wie sie ihre Werkzeuge nutzen sollen. Es ist ein Terminal und Browser mit einer CLI, und der Rest liegt bei Ihnen. + +cmux ist ein Grundbaustein, keine fertige Lösung. Es bietet Ihnen ein Terminal, einen Browser, Benachrichtigungen, Arbeitsbereiche, Teilungen, Tabs und eine CLI, um alles zu steuern. cmux zwingt Sie nicht in eine bestimmte Art, Coding-Agenten zu nutzen. Was Sie mit den Grundbausteinen bauen, ist Ihre Sache. + +Die besten Entwickler haben schon immer ihre eigenen Werkzeuge gebaut. Niemand hat bisher die beste Art gefunden, mit Agenten zu arbeiten, und die Teams, die geschlossene Produkte bauen, auch nicht. Die Entwickler, die ihren eigenen Codebasen am nächsten sind, werden es zuerst herausfinden. + +Geben Sie einer Million Entwickler komponierbare Grundbausteine, und sie werden gemeinsam die effizientesten Workflows schneller finden, als jedes Produktteam es von oben herab entwerfen könnte. + +## Dokumentation + +Weitere Informationen zur Konfiguration von cmux finden Sie in [unserer Dokumentation](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Tastenkürzel ### Arbeitsbereiche @@ -76,6 +135,7 @@ Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstell | ⌃ ⌘ ] | Nächster Arbeitsbereich | | ⌃ ⌘ [ | Vorheriger Arbeitsbereich | | ⌘ ⇧ W | Arbeitsbereich schließen | +| ⌘ ⇧ R | Arbeitsbereich umbenennen | | ⌘ B | Seitenleiste umschalten | ### Oberflächen @@ -102,6 +162,8 @@ Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstell ### Browser +Tastenkürzel für Browser-Entwicklertools folgen den Safari-Standardeinstellungen und sind in `Einstellungen → Tastenkürzel` anpassbar. + | Tastenkürzel | Aktion | |----------|--------| | ⌘ ⇧ L | Browser in Teilung öffnen | @@ -109,7 +171,8 @@ Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstell | ⌘ [ | Zurück | | ⌘ ] | Vorwärts | | ⌘ R | Seite neu laden | -| ⌥ ⌘ I | Entwicklertools öffnen | +| ⌥ ⌘ I | Entwicklertools umschalten (Safari-Standard) | +| ⌥ ⌘ C | JavaScript-Konsole anzeigen (Safari-Standard) | ### Benachrichtigungen @@ -146,6 +209,63 @@ Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstell | ⌘ ⇧ , | Konfiguration neu laden | | ⌘ Q | Beenden | +## Nightly Builds + +[cmux NIGHTLY herunterladen](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY ist eine separate App mit eigener Bundle-ID, die neben der stabilen Version läuft. Wird automatisch vom neuesten `main`-Commit gebaut und aktualisiert sich über einen eigenen Sparkle-Feed. + +## Sitzungswiederherstellung (aktuelles Verhalten) + +Beim Neustart stellt cmux derzeit nur App-Layout und Metadaten wieder her: +- Fenster-/Arbeitsbereich-/Bereichs-Layout +- Arbeitsverzeichnisse +- Terminal-Scrollback (bestmöglich) +- Browser-URL und Navigationsverlauf + +cmux stellt **keine** laufenden Prozesse in Terminal-Apps wieder her. Zum Beispiel werden aktive Claude Code-/tmux-/vim-Sitzungen nach einem Neustart noch nicht fortgesetzt. + +## Star-Verlauf + +<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> + +## Mitwirken + +Möglichkeiten, sich einzubringen: + +- Folgen Sie uns auf X für Updates [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) und [@austinywang](https://x.com/austinywang) +- Nehmen Sie an der Diskussion auf [Discord](https://discord.gg/xsgFEVrWCZ) teil +- Erstellen Sie [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) und beteiligen Sie sich an [Diskussionen](https://github.com/manaflow-ai/cmux/discussions) +- Lassen Sie uns wissen, was Sie mit cmux bauen + +## Community + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Founder's Edition + +cmux ist kostenlos, Open Source und wird es immer sein. Wenn Sie die Entwicklung unterstützen und frühen Zugang zu kommenden Funktionen erhalten möchten: + +**[Founder's Edition erhalten](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Priorisierte Feature-Requests/Bugfixes** +- **Früher Zugang: cmux AI, das Ihnen Kontext zu jedem Arbeitsbereich, Tab und Panel gibt** +- **Früher Zugang: iOS-App mit zwischen Desktop und Telefon synchronisierten Terminals** +- **Früher Zugang: Cloud-VMs** +- **Früher Zugang: Sprachmodus** +- **Meine persönliche iMessage/WhatsApp** + ## Lizenz Dieses Projekt ist unter der GNU Affero General Public License v3.0 oder neuer (`AGPL-3.0-or-later`) lizenziert. diff --git a/README.es.md b/README.es.md index 3e91ba59..1159c79e 100644 --- a/README.es.md +++ b/README.es.md @@ -1,7 +1,5 @@ > Esta traducción fue generada por Claude. Si tienes sugerencias de mejora, abre un PR. -<p align="center"><a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | Español | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a></p> - <h1 align="center">cmux</h1> <p align="center">Un terminal macOS basado en Ghostty con pestañas verticales y notificaciones para agentes de programación con IA</p> @@ -12,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="Captura de pantalla de cmux" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | Español | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="Captura de pantalla de cmux" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Video de demostración</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Características -- **Pestañas verticales** — La barra lateral muestra la rama de git, el directorio de trabajo, los puertos en escucha y el texto de la última notificación -- **Anillos de notificación** — Los paneles obtienen un anillo azul y las pestañas se iluminan cuando los agentes de IA (Claude Code, OpenCode) necesitan tu atención -- **Panel de notificaciones** — Ve todas las notificaciones pendientes en un solo lugar, salta a la más reciente no leída -- **Paneles divididos** — Divisiones horizontales y verticales -- **Navegador integrado** — Divide un navegador junto a tu terminal con una API programable portada de [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Anillos de notificación</h3> +Los paneles obtienen un anillo azul y las pestañas se iluminan cuando los agentes de programación necesitan tu atención +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Anillos de notificación" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Panel de notificaciones</h3> +Ve todas las notificaciones pendientes en un solo lugar, salta a la más reciente no leída +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Insignia de notificación en la barra lateral" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Navegador integrado</h3> +Divide un navegador junto a tu terminal con una API programable portada de <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Navegador integrado" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Pestañas verticales + horizontales</h3> +La barra lateral muestra la rama de git, el estado/número del PR vinculado, el directorio de trabajo, los puertos en escucha y el texto de la última notificación. Divide horizontal y verticalmente. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Pestañas verticales y paneles divididos" width="100%" /> +</td> +</tr> +</table> + - **Programable** — CLI y API de socket para crear espacios de trabajo, dividir paneles, enviar pulsaciones de teclas y automatizar el navegador - **App nativa de macOS** — Construida con Swift y AppKit, no con Electron. Inicio rápido, bajo consumo de memoria. - **Compatible con Ghostty** — Lee tu configuración existente en `~/.config/ghostty/config` para temas, fuentes y colores @@ -58,12 +103,26 @@ Ejecuto muchas sesiones de Claude Code y Codex en paralelo. Estaba usando Ghostt Probé algunos orquestadores de programación, pero la mayoría eran aplicaciones Electron/Tauri y el rendimiento me molestaba. Además, simplemente prefiero la terminal ya que los orquestadores con GUI te encierran en su flujo de trabajo. Así que construí cmux como una app nativa de macOS en Swift/AppKit. Usa libghostty para el renderizado del terminal y lee tu configuración existente de Ghostty para temas, fuentes y colores. -Las principales adiciones son la barra lateral y el sistema de notificaciones. La barra lateral tiene pestañas verticales que muestran la rama de git, el directorio de trabajo, los puertos en escucha y el texto de la última notificación para cada espacio de trabajo. El sistema de notificaciones detecta secuencias de terminal (OSC 9/99/777) y tiene un CLI (`cmux notify`) que puedes conectar a los hooks de agentes para Claude Code, OpenCode, etc. Cuando un agente está esperando, su panel obtiene un anillo azul y la pestaña se ilumina en la barra lateral, para que pueda saber cuál me necesita entre divisiones y pestañas. ⌘⇧U salta a la notificación no leída más reciente. +Las principales adiciones son la barra lateral y el sistema de notificaciones. La barra lateral tiene pestañas verticales que muestran la rama de git, el estado/número del PR vinculado, el directorio de trabajo, los puertos en escucha y el texto de la última notificación para cada espacio de trabajo. El sistema de notificaciones detecta secuencias de terminal (OSC 9/99/777) y tiene un CLI (`cmux notify`) que puedes conectar a los hooks de agentes para Claude Code, OpenCode, etc. Cuando un agente está esperando, su panel obtiene un anillo azul y la pestaña se ilumina en la barra lateral, para que pueda saber cuál me necesita entre divisiones y pestañas. ⌘⇧U salta a la notificación no leída más reciente. El navegador integrado tiene una API programable portada de [agent-browser](https://github.com/vercel-labs/agent-browser). Los agentes pueden capturar el árbol de accesibilidad, obtener referencias de elementos, hacer clic, rellenar formularios y ejecutar JS. Puedes dividir un panel de navegador junto a tu terminal y hacer que Claude Code interactúe directamente con tu servidor de desarrollo. Todo es programable a través del CLI y la API de socket — crear espacios de trabajo/pestañas, dividir paneles, enviar pulsaciones de teclas, abrir URLs en el navegador. +## The Zen of cmux + +cmux no prescribe cómo los desarrolladores deben usar sus herramientas. Es un terminal y navegador con un CLI, y el resto depende de ti. + +cmux es un primitivo, no una solución. Te da un terminal, un navegador, notificaciones, espacios de trabajo, divisiones, pestañas y un CLI para controlarlo todo. cmux no te obliga a usar los agentes de programación de una manera específica. Lo que construyas con los primitivos es tuyo. + +Los mejores desarrolladores siempre han construido sus propias herramientas. Nadie ha descubierto la mejor manera de trabajar con agentes todavía, y los equipos que construyen productos cerrados tampoco. Los desarrolladores más cercanos a sus propias bases de código lo descubrirán primero. + +Dale a un millón de desarrolladores primitivos componibles y encontrarán colectivamente los flujos de trabajo más eficientes más rápido de lo que cualquier equipo de producto podría diseñar de arriba hacia abajo. + +## Documentación + +Para más información sobre cómo configurar cmux, [visita nuestra documentación](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Atajos de teclado ### Espacios de trabajo @@ -76,6 +135,7 @@ Todo es programable a través del CLI y la API de socket — crear espacios de t | ⌃ ⌘ ] | Siguiente espacio de trabajo | | ⌃ ⌘ [ | Espacio de trabajo anterior | | ⌘ ⇧ W | Cerrar espacio de trabajo | +| ⌘ ⇧ R | Renombrar espacio de trabajo | | ⌘ B | Alternar barra lateral | ### Superficies @@ -102,6 +162,8 @@ Todo es programable a través del CLI y la API de socket — crear espacios de t ### Navegador +Los atajos de herramientas de desarrollo del navegador siguen los valores predeterminados de Safari y son personalizables en `Ajustes → Atajos de teclado`. + | Atajo | Acción | |----------|--------| | ⌘ ⇧ L | Abrir navegador en división | @@ -109,7 +171,8 @@ Todo es programable a través del CLI y la API de socket — crear espacios de t | ⌘ [ | Atrás | | ⌘ ] | Adelante | | ⌘ R | Recargar página | -| ⌥ ⌘ I | Abrir herramientas de desarrollo | +| ⌥ ⌘ I | Alternar herramientas de desarrollo (predeterminado de Safari) | +| ⌥ ⌘ C | Mostrar consola de JavaScript (predeterminado de Safari) | ### Notificaciones @@ -146,6 +209,63 @@ Todo es programable a través del CLI y la API de socket — crear espacios de t | ⌘ ⇧ , | Recargar configuración | | ⌘ Q | Salir | +## Compilaciones nocturnas + +[Descargar cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY es una app separada con su propio bundle ID, por lo que se ejecuta junto a la versión estable. Se compila automáticamente desde el último commit de `main` y se actualiza automáticamente a través de su propio feed de Sparkle. + +## Restauración de sesión (comportamiento actual) + +Al relanzar, cmux actualmente restaura solo el diseño y los metadatos de la aplicación: +- Diseño de ventanas/espacios de trabajo/paneles +- Directorios de trabajo +- Historial de desplazamiento del terminal (mejor esfuerzo) +- URL del navegador e historial de navegación + +cmux **no** restaura el estado de los procesos activos dentro de las aplicaciones de terminal. Por ejemplo, las sesiones activas de Claude Code/tmux/vim no se reanudan después de reiniciar todavía. + +## Historial de estrellas + +<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> + +## Contribuir + +Formas de participar: + +- Síguenos en X para actualizaciones [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) y [@austinywang](https://x.com/austinywang) +- Únete a la conversación en [Discord](https://discord.gg/xsgFEVrWCZ) +- Crea y participa en [GitHub issues](https://github.com/manaflow-ai/cmux/issues) y [discusiones](https://github.com/manaflow-ai/cmux/discussions) +- Cuéntanos qué estás construyendo con cmux + +## Comunidad + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Founder's Edition + +cmux es gratuito, de código abierto, y siempre lo será. Si deseas apoyar el desarrollo y obtener acceso anticipado a lo que viene: + +**[Obtener Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Solicitudes de funciones/corrección de errores priorizadas** +- **Acceso anticipado: cmux AI que te da contexto sobre cada espacio de trabajo, pestaña y panel** +- **Acceso anticipado: app de iOS con terminales sincronizadas entre escritorio y teléfono** +- **Acceso anticipado: VMs en la nube** +- **Acceso anticipado: Modo de voz** +- **Mi iMessage/WhatsApp personal** + ## Licencia Este proyecto está licenciado bajo la Licencia Pública General Affero de GNU v3.0 o posterior (`AGPL-3.0-or-later`). diff --git a/README.fr.md b/README.fr.md index fd003b6d..81cba423 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,7 +1,5 @@ > Cette traduction a été générée par Claude. Si vous avez des suggestions d'amélioration, ouvrez une PR. -<p align="center"><a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | Français | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a></p> - <h1 align="center">cmux</h1> <p align="center">Un terminal macOS basé sur Ghostty avec des onglets verticaux et des notifications pour les agents de programmation IA</p> @@ -12,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="Capture d'écran de cmux" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | Français | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="Capture d'écran de cmux" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Vidéo de démonstration</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Fonctionnalités -- **Onglets verticaux** — La barre latérale affiche la branche git, le répertoire de travail, les ports en écoute et le texte de la dernière notification -- **Anneaux de notification** — Les panneaux reçoivent un anneau bleu et les onglets s'illuminent lorsque les agents IA (Claude Code, OpenCode) ont besoin de votre attention -- **Panneau de notifications** — Consultez toutes les notifications en attente au même endroit, accédez directement à la plus récente non lue -- **Panneaux divisés** — Divisions horizontales et verticales -- **Navigateur intégré** — Divisez un navigateur à côté de votre terminal avec une API scriptable portée depuis [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Anneaux de notification</h3> +Les panneaux reçoivent un anneau bleu et les onglets s'illuminent lorsque les agents de programmation ont besoin de votre attention +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Anneaux de notification" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Panneau de notifications</h3> +Consultez toutes les notifications en attente au même endroit, accédez directement à la plus récente non lue +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Badge de notification dans la barre latérale" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Navigateur intégré</h3> +Divisez un navigateur à côté de votre terminal avec une API scriptable portée depuis <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Navigateur intégré" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Onglets verticaux + horizontaux</h3> +La barre latérale affiche la branche git, le statut/numéro de PR lié, le répertoire de travail, les ports en écoute et le texte de la dernière notification. Divisez horizontalement et verticalement. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Onglets verticaux et panneaux divisés" width="100%" /> +</td> +</tr> +</table> + - **Scriptable** — CLI et API socket pour créer des espaces de travail, diviser des panneaux, envoyer des frappes clavier et automatiser le navigateur - **Application macOS native** — Construite avec Swift et AppKit, pas Electron. Démarrage rapide, faible consommation mémoire. - **Compatible Ghostty** — Lit votre fichier `~/.config/ghostty/config` existant pour les thèmes, polices et couleurs @@ -58,12 +103,26 @@ J'exécute beaucoup de sessions Claude Code et Codex en parallèle. J'utilisais J'ai essayé quelques orchestrateurs de programmation, mais la plupart étaient des applications Electron/Tauri et les performances me dérangeaient. Je préfère aussi simplement le terminal, car les orchestrateurs à interface graphique vous enferment dans leur flux de travail. J'ai donc construit cmux comme une application macOS native en Swift/AppKit. Elle utilise libghostty pour le rendu du terminal et lit votre configuration Ghostty existante pour les thèmes, polices et couleurs. -Les principaux ajouts sont la barre latérale et le système de notifications. La barre latérale comporte des onglets verticaux qui affichent la branche git, le répertoire de travail, les ports en écoute et le texte de la dernière notification pour chaque espace de travail. Le système de notifications capte les séquences de terminal (OSC 9/99/777) et dispose d'un CLI (`cmux notify`) que vous pouvez brancher aux hooks d'agents pour Claude Code, OpenCode, etc. Quand un agent est en attente, son panneau reçoit un anneau bleu et l'onglet s'illumine dans la barre latérale, pour que je puisse identifier lequel a besoin de moi parmi les divisions et les onglets. ⌘⇧U permet de sauter à la notification non lue la plus récente. +Les principaux ajouts sont la barre latérale et le système de notifications. La barre latérale comporte des onglets verticaux qui affichent la branche git, le statut/numéro de PR lié, le répertoire de travail, les ports en écoute et le texte de la dernière notification pour chaque espace de travail. Le système de notifications capte les séquences de terminal (OSC 9/99/777) et dispose d'un CLI (`cmux notify`) que vous pouvez brancher aux hooks d'agents pour Claude Code, OpenCode, etc. Quand un agent est en attente, son panneau reçoit un anneau bleu et l'onglet s'illumine dans la barre latérale, pour que je puisse identifier lequel a besoin de moi parmi les divisions et les onglets. ⌘⇧U permet de sauter à la notification non lue la plus récente. Le navigateur intégré dispose d'une API scriptable portée depuis [agent-browser](https://github.com/vercel-labs/agent-browser). Les agents peuvent capturer l'arbre d'accessibilité, obtenir des références d'éléments, cliquer, remplir des formulaires et exécuter du JS. Vous pouvez diviser un panneau navigateur à côté de votre terminal et laisser Claude Code interagir directement avec votre serveur de développement. Tout est scriptable via le CLI et l'API socket — créer des espaces de travail/onglets, diviser des panneaux, envoyer des frappes clavier, ouvrir des URL dans le navigateur. +## The Zen of cmux + +cmux ne prescrit pas comment les développeurs utilisent leurs outils. C'est un terminal et un navigateur avec un CLI, le reste vous appartient. + +cmux est une primitive, pas une solution. Il vous donne un terminal, un navigateur, des notifications, des espaces de travail, des divisions, des onglets et un CLI pour tout contrôler. cmux ne vous impose pas une façon préconçue d'utiliser les agents de programmation. Ce que vous construisez avec ces primitives vous appartient. + +Les meilleurs développeurs ont toujours construit leurs propres outils. Personne n'a encore trouvé la meilleure façon de travailler avec les agents, et les équipes qui construisent des produits fermés ne l'ont pas trouvée non plus. Les développeurs les plus proches de leurs propres bases de code trouveront la solution en premier. + +Donnez à un million de développeurs des primitives composables et ils trouveront collectivement les flux de travail les plus efficaces plus rapidement que n'importe quelle équipe produit ne pourrait les concevoir de manière descendante. + +## Documentation + +Pour plus d'informations sur la configuration de cmux, [consultez notre documentation](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Raccourcis clavier ### Espaces de travail @@ -76,6 +135,7 @@ Tout est scriptable via le CLI et l'API socket — créer des espaces de travail | ⌃ ⌘ ] | Espace de travail suivant | | ⌃ ⌘ [ | Espace de travail précédent | | ⌘ ⇧ W | Fermer l'espace de travail | +| ⌘ ⇧ R | Renommer l'espace de travail | | ⌘ B | Basculer la barre latérale | ### Surfaces @@ -102,6 +162,8 @@ Tout est scriptable via le CLI et l'API socket — créer des espaces de travail ### Navigateur +Les raccourcis des outils de développement du navigateur suivent les valeurs par défaut de Safari et sont personnalisables dans `Paramètres → Raccourcis clavier`. + | Raccourci | Action | |----------|--------| | ⌘ ⇧ L | Ouvrir le navigateur en division | @@ -109,7 +171,8 @@ Tout est scriptable via le CLI et l'API socket — créer des espaces de travail | ⌘ [ | Reculer | | ⌘ ] | Avancer | | ⌘ R | Recharger la page | -| ⌥ ⌘ I | Ouvrir les outils de développement | +| ⌥ ⌘ I | Basculer les outils de développement (par défaut Safari) | +| ⌥ ⌘ C | Afficher la console JavaScript (par défaut Safari) | ### Notifications @@ -146,6 +209,63 @@ Tout est scriptable via le CLI et l'API socket — créer des espaces de travail | ⌘ ⇧ , | Recharger la configuration | | ⌘ Q | Quitter | +## Builds Nightly + +[Télécharger cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY est une application séparée avec son propre identifiant de bundle, elle fonctionne donc en parallèle de la version stable. Construite automatiquement à partir du dernier commit `main` et mise à jour automatiquement via son propre flux Sparkle. + +## Restauration de session (comportement actuel) + +Au relancement, cmux restaure actuellement uniquement la disposition et les métadonnées de l'application : +- Disposition des fenêtres/espaces de travail/panneaux +- Répertoires de travail +- Historique de défilement du terminal (au mieux) +- URL du navigateur et historique de navigation + +cmux ne restaure **pas** l'état des processus actifs dans les applications du terminal. Par exemple, les sessions actives de Claude Code/tmux/vim ne sont pas encore reprises après un redémarrage. + +## Historique des étoiles + +<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> + +## Contribuer + +Façons de s'impliquer : + +- Suivez-nous sur X pour les mises à jour [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), et [@austinywang](https://x.com/austinywang) +- Rejoignez la conversation sur [Discord](https://discord.gg/xsgFEVrWCZ) +- Créez et participez aux [issues GitHub](https://github.com/manaflow-ai/cmux/issues) et aux [discussions](https://github.com/manaflow-ai/cmux/discussions) +- Dites-nous ce que vous construisez avec cmux + +## Communauté + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Édition Fondateur + +cmux est gratuit, open source, et le restera toujours. Si vous souhaitez soutenir le développement et obtenir un accès anticipé à ce qui arrive : + +**[Obtenir l'Édition Fondateur](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Demandes de fonctionnalités et corrections de bugs prioritaires** +- **Accès anticipé : cmux AI qui vous donne du contexte sur chaque espace de travail, onglet et panneau** +- **Accès anticipé : application iOS avec des terminaux synchronisés entre ordinateur et téléphone** +- **Accès anticipé : VMs cloud** +- **Accès anticipé : Mode vocal** +- **Mon iMessage/WhatsApp personnel** + ## Licence Ce projet est sous licence GNU Affero General Public License v3.0 ou ultérieure (`AGPL-3.0-or-later`). diff --git a/README.it.md b/README.it.md index a2f07906..cfc97d8a 100644 --- a/README.it.md +++ b/README.it.md @@ -1,9 +1,5 @@ > Questa traduzione è stata generata da Claude. Se hai suggerimenti per migliorarla, apri una PR. -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | Italiano | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">Un terminale macOS basato su Ghostty con schede verticali e notifiche per agenti di programmazione AI</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="Screenshot di cmux" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | Italiano | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="Screenshot di cmux" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Video demo</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Funzionalità -- **Schede verticali** — La barra laterale mostra il branch git, la directory di lavoro, le porte in ascolto e il testo dell'ultima notifica -- **Anelli di notifica** — I pannelli ricevono un anello blu e le schede si illuminano quando gli agenti AI (Claude Code, OpenCode) richiedono la tua attenzione -- **Pannello notifiche** — Visualizza tutte le notifiche in sospeso in un unico posto, salta alla più recente non letta -- **Pannelli divisi** — Divisioni orizzontali e verticali -- **Browser integrato** — Dividi un browser accanto al tuo terminale con un'API scriptabile derivata da [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Anelli di notifica</h3> +I pannelli ricevono un anello blu e le schede si illuminano quando gli agenti di programmazione richiedono la tua attenzione +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Anelli di notifica" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Pannello notifiche</h3> +Visualizza tutte le notifiche in sospeso in un unico posto, salta alla più recente non letta +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Badge notifica nella barra laterale" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Browser integrato</h3> +Dividi un browser accanto al tuo terminale con un'API scriptabile derivata da <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Browser integrato" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Schede verticali + orizzontali</h3> +La barra laterale mostra il branch git, lo stato/numero della PR collegata, la directory di lavoro, le porte in ascolto e il testo dell'ultima notifica. Dividi orizzontalmente e verticalmente. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Schede verticali e pannelli divisi" width="100%" /> +</td> +</tr> +</table> + - **Scriptabile** — CLI e socket API per creare workspace, dividere pannelli, inviare sequenze di tasti e automatizzare il browser - **App macOS nativa** — Costruita con Swift e AppKit, non Electron. Avvio rapido, basso consumo di memoria. - **Compatibile con Ghostty** — Legge la tua configurazione esistente `~/.config/ghostty/config` per temi, font e colori @@ -60,12 +103,26 @@ Eseguo molte sessioni di Claude Code e Codex in parallelo. Usavo Ghostty con un Ho provato alcuni orchestratori di codifica, ma la maggior parte erano app Electron/Tauri e le prestazioni mi infastidivano. Inoltre preferisco semplicemente il terminale dato che gli orchestratori con interfaccia grafica ti vincolano al loro flusso di lavoro. Così ho costruito cmux come app macOS nativa in Swift/AppKit. Usa libghostty per il rendering del terminale e legge la tua configurazione Ghostty esistente per temi, font e colori. -Le aggiunte principali sono la barra laterale e il sistema di notifiche. La barra laterale ha schede verticali che mostrano il branch git, la directory di lavoro, le porte in ascolto e il testo dell'ultima notifica per ogni workspace. Il sistema di notifiche rileva le sequenze terminale (OSC 9/99/777) e ha un CLI (`cmux notify`) che puoi collegare agli hook degli agenti per Claude Code, OpenCode, ecc. Quando un agente è in attesa, il suo pannello riceve un anello blu e la scheda si illumina nella barra laterale, così posso capire quale ha bisogno di me tra divisioni e schede. Cmd+Shift+U salta alla più recente non letta. +Le aggiunte principali sono la barra laterale e il sistema di notifiche. La barra laterale ha schede verticali che mostrano il branch git, lo stato/numero della PR collegata, la directory di lavoro, le porte in ascolto e il testo dell'ultima notifica per ogni workspace. Il sistema di notifiche rileva le sequenze terminale (OSC 9/99/777) e ha un CLI (`cmux notify`) che puoi collegare agli hook degli agenti per Claude Code, OpenCode, ecc. Quando un agente è in attesa, il suo pannello riceve un anello blu e la scheda si illumina nella barra laterale, così posso capire quale ha bisogno di me tra divisioni e schede. Cmd+Shift+U salta alla più recente non letta. Il browser integrato ha un'API scriptabile derivata da [agent-browser](https://github.com/vercel-labs/agent-browser). Gli agenti possono acquisire l'albero di accessibilità, ottenere riferimenti agli elementi, fare clic, compilare moduli e valutare JS. Puoi dividere un pannello browser accanto al tuo terminale e far interagire Claude Code direttamente con il tuo server di sviluppo. Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/schede, dividere pannelli, inviare sequenze di tasti, aprire URL nel browser. +## The Zen of cmux + +cmux non prescrive come gli sviluppatori usano i propri strumenti. È un terminale e un browser con un CLI, il resto dipende da te. + +cmux è una primitiva, non una soluzione. Ti dà un terminale, un browser, notifiche, workspace, divisioni, schede e un CLI per controllare tutto. cmux non ti obbliga a usare gli agenti di programmazione in un modo predefinito. Quello che costruisci con le primitive è tuo. + +I migliori sviluppatori hanno sempre costruito i propri strumenti. Nessuno ha ancora trovato il modo migliore di lavorare con gli agenti, e i team che costruiscono prodotti chiusi non l'hanno trovato nemmeno loro. Gli sviluppatori più vicini alle proprie basi di codice lo troveranno per primi. + +Date a un milione di sviluppatori primitive componibili e troveranno collettivamente i flussi di lavoro più efficienti più velocemente di quanto qualsiasi team di prodotto potrebbe progettare dall'alto. + +## Documentazione + +Per maggiori informazioni su come configurare cmux, [consulta la nostra documentazione](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Scorciatoie da Tastiera ### Workspace @@ -78,6 +135,7 @@ Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/sche | ⌃ ⌘ ] | Workspace successivo | | ⌃ ⌘ [ | Workspace precedente | | ⌘ ⇧ W | Chiudi workspace | +| ⌘ ⇧ R | Rinomina workspace | | ⌘ B | Mostra/nascondi barra laterale | ### Superfici @@ -104,6 +162,8 @@ Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/sche ### Browser +Le scorciatoie degli strumenti di sviluppo del browser seguono i valori predefiniti di Safari e sono personalizzabili in `Impostazioni → Scorciatoie da tastiera`. + | Scorciatoia | Azione | |----------|--------| | ⌘ ⇧ L | Apri browser in divisione | @@ -111,7 +171,8 @@ Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/sche | ⌘ [ | Indietro | | ⌘ ] | Avanti | | ⌘ R | Ricarica pagina | -| ⌥ ⌘ I | Apri Strumenti di Sviluppo | +| ⌥ ⌘ I | Mostra/Nascondi Strumenti di Sviluppo (predefinito Safari) | +| ⌥ ⌘ C | Mostra Console JavaScript (predefinito Safari) | ### Notifiche @@ -148,6 +209,63 @@ Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/sche | ⌘ ⇧ , | Ricarica configurazione | | ⌘ Q | Esci | +## Build Nightly + +[Scarica cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY è un'app separata con il proprio bundle ID, quindi funziona in parallelo alla versione stabile. Compilata automaticamente dall'ultimo commit `main` e aggiornata automaticamente tramite il proprio feed Sparkle. + +## Ripristino sessione (comportamento attuale) + +Al riavvio, cmux attualmente ripristina solo il layout e i metadati dell'applicazione: +- Layout di finestre/workspace/pannelli +- Directory di lavoro +- Scrollback del terminale (best effort) +- URL del browser e cronologia di navigazione + +cmux **non** ripristina lo stato dei processi attivi nelle applicazioni del terminale. Per esempio, le sessioni attive di Claude Code/tmux/vim non vengono ancora riprese dopo un riavvio. + +## Cronologia Stelle + +<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> + +## Contribuire + +Modi per partecipare: + +- Seguici su X per aggiornamenti [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), e [@austinywang](https://x.com/austinywang) +- Unisciti alla conversazione su [Discord](https://discord.gg/xsgFEVrWCZ) +- Crea e partecipa alle [issue su GitHub](https://github.com/manaflow-ai/cmux/issues) e alle [discussioni](https://github.com/manaflow-ai/cmux/discussions) +- Facci sapere cosa stai costruendo con cmux + +## Comunità + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Edizione Fondatore + +cmux è gratuito, open source, e lo sarà sempre. Se vuoi supportare lo sviluppo e ottenere accesso anticipato a ciò che arriverà: + +**[Ottieni l'Edizione Fondatore](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Richieste di funzionalità e correzioni di bug prioritarie** +- **Accesso anticipato: cmux AI che ti dà contesto su ogni workspace, scheda e pannello** +- **Accesso anticipato: app iOS con terminali sincronizzati tra desktop e telefono** +- **Accesso anticipato: VM cloud** +- **Accesso anticipato: Modalità vocale** +- **Il mio iMessage/WhatsApp personale** + ## Licenza Questo progetto è distribuito sotto la GNU Affero General Public License v3.0 o successiva (`AGPL-3.0-or-later`). diff --git a/README.ja.md b/README.ja.md index 3c304c32..9d3bdc50 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,9 +1,5 @@ > この翻訳は Claude によって生成されました。改善の提案がある場合は、PR を作成してください。 -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | 日本語 | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">AIコーディングエージェント向けの縦タブと通知機能を備えたGhosttyベースのmacOSターミナル</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="cmuxスクリーンショット" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | 日本語 | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmuxスクリーンショット" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ デモ動画</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## 機能 -- **縦タブ** — サイドバーにgitブランチ、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示 -- **通知リング** — AIエージェント(Claude Code、OpenCode)があなたの注意を必要とするとき、ペインに青いリングが表示され、タブが点灯 -- **通知パネル** — 保留中のすべての通知を一か所で確認、最新の未読にジャンプ -- **分割ペイン** — 水平・垂直分割 -- **アプリ内ブラウザ** — [agent-browser](https://github.com/vercel-labs/agent-browser)から移植されたスクリプタブルなAPIで、ターミナルの横にブラウザを分割表示 +<table> +<tr> +<td width="40%" valign="middle"> +<h3>通知リング</h3> +コーディングエージェントがあなたの注意を必要とするとき、ペインに青いリングが表示され、タブが点灯します +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="通知リング" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>通知パネル</h3> +保留中のすべての通知を一か所で確認、最新の未読にジャンプ +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="サイドバー通知バッジ" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>アプリ内ブラウザ</h3> +<a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>から移植されたスクリプタブルなAPIで、ターミナルの横にブラウザを分割表示 +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="内蔵ブラウザ" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>縦タブ + 横タブ</h3> +サイドバーにgitブランチ、リンクされたPRのステータス/番号、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示。水平・垂直に分割可能。 +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="縦タブと分割ペイン" width="100%" /> +</td> +</tr> +</table> + - **スクリプタブル** — CLIとsocket APIでワークスペースの作成、ペインの分割、キーストロークの送信、ブラウザの自動化が可能 - **ネイティブmacOSアプリ** — SwiftとAppKitで構築、Electronではありません。高速起動、低メモリ消費。 - **Ghostty互換** — 既存の`~/.config/ghostty/config`からテーマ、フォント、カラーを読み込み @@ -60,12 +103,26 @@ brew upgrade --cask cmux いくつかのコーディングオーケストレーターを試しましたが、そのほとんどがElectron/Tauriアプリで、パフォーマンスが気になりました。また、GUIオーケストレーターはそのワークフローに縛られるため、単純にターミナルのほうが好みです。そこで、cmuxをSwift/AppKitのネイティブmacOSアプリとして構築しました。ターミナルレンダリングにはlibghosttyを使用し、テーマ、フォント、カラーは既存のGhostty設定を読み込みます。 -主な追加機能はサイドバーと通知システムです。サイドバーには、各ワークスペースのgitブランチ、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示する縦タブがあります。通知システムはターミナルシーケンス(OSC 9/99/777)を検出し、Claude Code、OpenCodeなどのエージェントフックに接続できるCLI(`cmux notify`)を備えています。エージェントが待機中のとき、そのペインに青いリングが表示され、サイドバーのタブが点灯するので、分割やタブをまたいでどれが私を必要としているかがわかります。Cmd+Shift+Uで最新の未読にジャンプします。 +主な追加機能はサイドバーと通知システムです。サイドバーには、各ワークスペースのgitブランチ、リンクされたPRのステータス/番号、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示する縦タブがあります。通知システムはターミナルシーケンス(OSC 9/99/777)を検出し、Claude Code、OpenCodeなどのエージェントフックに接続できるCLI(`cmux notify`)を備えています。エージェントが待機中のとき、そのペインに青いリングが表示され、サイドバーのタブが点灯するので、分割やタブをまたいでどれが私を必要としているかがわかります。Cmd+Shift+Uで最新の未読にジャンプします。 アプリ内ブラウザには[agent-browser](https://github.com/vercel-labs/agent-browser)から移植されたスクリプタブルなAPIがあります。エージェントはアクセシビリティツリーのスナップショットを取得し、要素参照を取得し、クリック、フォーム入力、JSの評価が可能です。ターミナルの横にブラウザペインを分割し、Claude Codeに開発サーバーと直接やり取りさせることができます。 すべてがCLIとsocket APIを通じてスクリプタブルです — ワークスペース/タブの作成、ペインの分割、キーストロークの送信、ブラウザでのURL表示。 +## The Zen of cmux + +cmuxは開発者のツールの使い方を規定しません。ターミナルとブラウザにCLIがあり、あとはあなた次第です。 + +cmuxはソリューションではなくプリミティブです。ターミナル、ブラウザ、通知、ワークスペース、分割、タブ、そしてそのすべてを制御するCLIを提供します。cmuxはコーディングエージェントの使い方を強制しません。プリミティブで何を構築するかはあなた次第です。 + +優れた開発者は常に自分のツールを構築してきました。エージェントとの最適な作業方法はまだ誰も見つけていませんし、クローズドな製品を作っているチームも見つけていません。自分のコードベースに最も近い開発者が最初に見つけるでしょう。 + +100万人の開発者にコンポーザブルなプリミティブを与えれば、どんなプロダクトチームがトップダウンで設計するよりも速く、最も効率的なワークフローを集合的に見つけ出すでしょう。 + +## ドキュメント + +cmuxの設定方法の詳細は、[ドキュメントをご覧ください](https://cmux.dev/docs/getting-started?utm_source=readme)。 + ## キーボードショートカット ### ワークスペース @@ -78,6 +135,7 @@ brew upgrade --cask cmux | ⌃ ⌘ ] | 次のワークスペース | | ⌃ ⌘ [ | 前のワークスペース | | ⌘ ⇧ W | ワークスペースを閉じる | +| ⌘ ⇧ R | ワークスペースの名前を変更 | | ⌘ B | サイドバーの表示切替 | ### サーフェス @@ -104,6 +162,8 @@ brew upgrade --cask cmux ### ブラウザ +ブラウザの開発者ツールのショートカットはSafariのデフォルトに従い、`設定 → キーボードショートカット`でカスタマイズできます。 + | ショートカット | アクション | |----------|--------| | ⌘ ⇧ L | 分割でブラウザを開く | @@ -111,7 +171,8 @@ brew upgrade --cask cmux | ⌘ [ | 戻る | | ⌘ ] | 進む | | ⌘ R | ページを再読み込み | -| ⌥ ⌘ I | 開発者ツールを開く | +| ⌥ ⌘ I | 開発者ツールの表示切替(Safariデフォルト) | +| ⌥ ⌘ C | JavaScriptコンソールを表示(Safariデフォルト) | ### 通知 @@ -148,6 +209,63 @@ brew upgrade --cask cmux | ⌘ ⇧ , | 設定を再読み込み | | ⌘ Q | 終了 | +## ナイトリービルド + +[cmux NIGHTLYをダウンロード](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLYは独自のバンドルIDを持つ別のアプリなので、安定版と並行して実行できます。最新の`main`コミットから自動的にビルドされ、独自のSparkleフィード経由で自動更新されます。 + +## セッション復元(現在の動作) + +再起動時、cmuxは現在アプリのレイアウトとメタデータのみを復元します: +- ウィンドウ/ワークスペース/ペインのレイアウト +- 作業ディレクトリ +- ターミナルのスクロールバック(ベストエフォート) +- ブラウザのURLとナビゲーション履歴 + +cmuxはターミナルアプリ内のライブプロセスの状態を復元**しません**。例えば、アクティブなClaude Code/tmux/vimセッションは再起動後にまだ再開されません。 + +## 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> + +## コントリビューション + +参加方法: + +- Xでフォロー:[@manaflowai](https://x.com/manaflowai)、[@lawrencecchen](https://x.com/lawrencecchen)、[@austinywang](https://x.com/austinywang) +- [Discord](https://discord.gg/xsgFEVrWCZ)で会話に参加 +- [GitHubのIssues](https://github.com/manaflow-ai/cmux/issues)や[ディスカッション](https://github.com/manaflow-ai/cmux/discussions)に参加 +- cmuxで何を構築しているか教えてください + +## コミュニティ + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Founder's Edition + +cmuxは無料でオープンソースであり、今後もそうあり続けます。開発をサポートし、次に来る機能への早期アクセスを得たい方へ: + +**[Founder's Editionを入手](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **機能リクエスト/バグ修正の優先対応** +- **早期アクセス:すべてのワークスペース、タブ、パネルのコンテキストを提供するcmux AI** +- **早期アクセス:デスクトップと携帯電話間でターミナルを同期するiOSアプリ** +- **早期アクセス:クラウドVM** +- **早期アクセス:ボイスモード** +- **私の個人的なiMessage/WhatsApp** + ## ライセンス このプロジェクトはGNU Affero General Public License v3.0以降(`AGPL-3.0-or-later`)の下でライセンスされています。 diff --git a/README.km.md b/README.km.md new file mode 100644 index 00000000..f083816c --- /dev/null +++ b/README.km.md @@ -0,0 +1,274 @@ +> ការបកប្រែនេះត្រូវបានបង្កើតដោយ Claude។ ប្រសិនបើអ្នកមានការកែលម្អ សូមបង្កើត PR។ + +<h1 align="center">cmux</h1> +<p align="center">Terminal សម្រាប់ macOS ផ្អែកលើ Ghostty ដែលមាន tab បញ្ឈរ និងការជូនដំណឹងសម្រាប់ AI coding agents</p> + +<p align="center"> + <a href="https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg"> + <img src="./docs/assets/macos-badge.png" alt="Download cmux for macOS" width="180" /> + </a> +</p> + +<p align="center"> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | ភាសាខ្មែរ +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmux screenshot" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ វីដេអូបង្ហាញពីដំណើរការ (Demo)</a> · <a href="https://cmux.dev/blog/zen-of-cmux">ទស្សនវិជ្ជារបស់ cmux (The Zen of cmux)</a> +</p> + +## លក្ខណៈពិសេសនានា (Features) + +<table> +<tr> +<td width="40%" valign="middle"> +<h3>រង្វង់ជូនដំណឹង (Notification rings)</h3> +ផ្ទាំង (Panes) នឹងមានរង្វង់ពណ៌ខៀវ ហើយ tabs នឹងភ្លឺឡើង នៅពេល coding agents ត្រូវការការយកចិត្តទុកដាក់របស់អ្នក +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Notification rings" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>ផ្ទាំងជូនដំណឹង (Notification panel)</h3> +មើលការជូនដំណឹងដែលកំពុងរង់ចាំទាំងអស់នៅកន្លែងតែមួយ លោតទៅកាន់សារមិនទាន់អានថ្មីបំផុត +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Sidebar notification badge" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>កម្មវិធីរុករកក្នុងកម្មវិធី (In-app browser)</h3> +បំបែកកម្មវិធីរុករកនៅក្បែរ terminal របស់អ្នកជាមួយ scriptable API ដែលបានយកចេញពី <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Built-in browser" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Tab បញ្ឈរ + ផ្ដេក (Vertical + horizontal tabs)</h3> +របារចំហៀងបង្ហាញ git branch, ស្ថានភាព/លេខ PR, ថតការងារ, port ដែលកំពុងស្តាប់ និងអត្ថបទជូនដំណឹងចុងក្រោយ។ បំបែកទាំងផ្ដេក និងបញ្ឈរ។ +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertical tabs and split panes" width="100%" /> +</td> +</tr> +</table> + +* **អាចសរសេរ Script បាន (Scriptable)** — CLI និង socket API ដើម្បីបង្កើត workspaces, បំបែក panes, បញ្ជូន keystrokes, និងធ្វើស្វ័យប្រវត្តិកម្មកម្មវិធីរុករក (browser) +* **កម្មវិធីដើមរបស់ macOS (Native macOS app)** — បង្កើតឡើងដោយប្រើ Swift និង AppKit មិនមែន Electron ទេ។ ចាប់ផ្តើមលឿន, ស៊ីមេម៉ូរី (memory) តិច។ +* **ត្រូវគ្នាជាមួយ Ghostty (Ghostty compatible)** — អានការកំណត់ `~/.config/ghostty/config` ដែលអ្នកមានស្រាប់សម្រាប់ theme, font, និងពណ៌ +* **បង្កើនល្បឿនដោយ GPU (GPU-accelerated)** — ដំណើរការដោយ libghostty ដើម្បីការបង្ហាញរូបភាពរលូនល្អ (smooth rendering) + +## ការដំឡើង (Install) + +### DMG (ត្រូវបានណែនាំ) + +<a href="https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg"> + <img src="./docs/assets/macos-badge.png" alt="ទាញយក cmux សម្រាប់ macOS" width="180" /> +</a> + +បើកឯកសារ `.dmg` ហើយអូស cmux បញ្ចូលទៅក្នុងថត Applications របស់អ្នក។ cmux ធ្វើបច្ចុប្បន្នភាពដោយស្វ័យប្រវត្តិតាមរយៈ Sparkle ដូច្នេះអ្នកគ្រាន់តែទាញយកវាតែម្តងគត់។ + +### Homebrew + +```bash +brew tap manaflow-ai/cmux +brew install --cask cmux +``` + +ដើម្បីធ្វើបច្ចុប្បន្នភាពនៅពេលក្រោយ៖ + +```bash +brew upgrade --cask cmux +``` + +នៅពេលបើកដំណើរការជាលើកដំបូង macOS អាចនឹងសុំឱ្យអ្នកបញ្ជាក់ការបើកកម្មវិធីពីអ្នកអភិវឌ្ឍន៍ដែលបានកំណត់អត្តសញ្ញាណ។ ចុច **Open** ដើម្បីបន្ត។ + +## ហេតុអ្វីត្រូវជ្រើសរើស cmux? + +ខ្ញុំបើកដំណើរការ Claude Code និង Codex ច្រើនក្នុងពេលតែមួយ។ ខ្ញុំធ្លាប់ប្រើ Ghostty ជាមួយ split panes ជាច្រើន ហើយពឹងផ្អែកលើការជូនដំណឹងដើមរបស់ macOS ដើម្បីដឹងថានៅពេលណាដែល agent ត្រូវការខ្ញុំ។ ប៉ុន្តែខ្លឹមសារជូនដំណឹងរបស់ Claude Code តែងតែសរសេរត្រឹម "Claude វាកំពុងរង់ចាំការបញ្ចូលព័ត៌មានពីអ្នក" ដោយគ្មានបរិបទ (context) ហើយនៅពេលដែលបើក tab ច្រើនពេក ខ្ញុំសឹងតែមិនអាចអានចំណងជើងបានទៀតផង។ + +ខ្ញុំបានសាកល្បងប្រើ coding orchestrators មួយចំនួន ប៉ុន្តែភាគច្រើននៃពួកវាគឺជាកម្មវិធី Electron/Tauri ហើយដំណើរការ (performance) របស់វារំខានដល់ខ្ញុំ។ ម្យ៉ាងទៀត ខ្ញុំចូលចិត្តប្រើ terminal ជាង ពីព្រោះ GUI orchestrators តែងតែកំណត់លំហូរការងារ (workflow) របស់អ្នក។ ដូច្នេះ ខ្ញុំបានបង្កើត cmux ជាកម្មវិធីដើមសម្រាប់ macOS នៅក្នុង Swift/AppKit។ វាប្រើប្រាស់ libghostty សម្រាប់ការបង្ហាញ terminal និងអាន config របស់ Ghostty ដែលអ្នកមានស្រាប់សម្រាប់ themes, fonts និងពណ៌។ + +ការបន្ថែមដ៏សំខាន់គឺរបារចំហៀង (sidebar) និងប្រព័ន្ធជូនដំណឹង។ របារចំហៀងមាន tab បញ្ឈរដែលបង្ហាញពី git branch, ស្ថានភាព/លេខ PR, ថតការងារ, port ដែលកំពុងស្តាប់ និងអត្ថបទជូនដំណឹងចុងក្រោយសម្រាប់ workspace នីមួយៗ។ ប្រព័ន្ធជូនដំណឹងចាប់យក terminal sequences (OSC 9/99/777) និងមាន CLI (`cmux notify`) ដែលអ្នកអាចភ្ជាប់ទៅកាន់ agent hooks សម្រាប់ Claude Code, OpenCode ជាដើម។ នៅពេល agent កំពុងរង់ចាំ ផ្ទាំង (pane) របស់វានឹងមានរង្វង់ពណ៌ខៀវ ហើយ tab នឹងភ្លឺឡើងនៅលើរបារចំហៀង ដូច្នេះខ្ញុំអាចដឹងថាមួយណាដែលត្រូវការខ្ញុំនៅទូទាំង splits និង tabs ទាំងអស់។ ចុច Cmd+Shift+U ដើម្បីលោតទៅកាន់សារមិនទាន់អានថ្មីបំផុត។ + +កម្មវិធីរុករកក្នុងកម្មវិធី (in-app browser) មាន scriptable API ដែលបានយកចេញពី [agent-browser](https://github.com/vercel-labs/agent-browser)។ Agents អាចថតចម្លង (snapshot) ដើមឈើភាពងាយស្រួល (accessibility tree), យក element refs, ចុច (click), បំពេញទម្រង់បែបបទ (fill forms) និងវាយតម្លៃ (evaluate) JS។ អ្នកអាចបំបែកផ្ទាំងកម្មវិធីរុករកនៅក្បែរ terminal របស់អ្នក ហើយឱ្យ Claude Code ប្រាស្រ័យទាក់ទងដោយផ្ទាល់ជាមួយ dev server របស់អ្នក។ + +អ្វីៗទាំងអស់អាចសរសេរ script បានតាមរយៈ CLI និង socket API — បង្កើត workspaces/tabs, បំបែក panes, បញ្ជូន keystrokes, បើក URLs នៅក្នុងកម្មវិធីរុករក។ + +## ទស្សនវិជ្ជារបស់ cmux (The Zen of cmux) + +cmux មិនបង្ខំអំពីរបៀបដែលអ្នកអភិវឌ្ឍន៍ប្រើប្រាស់ឧបករណ៍របស់ពួកគេទេ។ វាគឺជា terminal និងកម្មវិធីរុករកដែលមាន CLI ហើយអ្វីៗផ្សេងទៀតគឺអាស្រ័យលើអ្នក។ + +cmux គឺជាមូលដ្ឋានគ្រឹះ (primitive) មិនមែនជាដំណោះស្រាយពេញលេញទេ។ វាផ្តល់ឱ្យអ្នកនូវ terminal, កម្មវិធីរុករក, ការជូនដំណឹង, workspaces, splits, tabs និង CLI ដើម្បីគ្រប់គ្រងអ្វីៗទាំងអស់នេះ។ cmux មិនបង្ខំអ្នកឱ្យប្រើវិធីសាស្ត្រណាមួយដែលវាបានកំណត់ទុកមុនក្នុងការប្រើប្រាស់ coding agents នោះទេ។ អ្វីដែលអ្នកបង្កើតជាមួយមូលដ្ឋានគ្រឹះទាំងនេះ គឺជារបស់អ្នក។ + +អ្នកអភិវឌ្ឍន៍ដ៏ល្អបំផុតតែងតែបង្កើតឧបករណ៍ដោយខ្លួនឯង។ មិនទាន់មាននរណាម្នាក់រកឃើញវិធីល្អបំផុតក្នុងការធ្វើការជាមួយ agents នៅឡើយទេ ហើយក្រុមដែលបង្កើតផលិតផលបិទជិត (closed products) ក៏ច្បាស់ជាមិនទាន់រកឃើញដូចគ្នា។ អ្នកអភិវឌ្ឍន៍ដែលយល់ច្បាស់ពី codebases របស់ពួកគេ នឹងរកឃើញវាមុនគេ។ + +ផ្តល់ឱ្យអ្នកអភិវឌ្ឍន៍មួយលាននាក់នូវមូលដ្ឋានគ្រឹះដែលអាចផ្សំបញ្ចូលគ្នាបាន នោះពួកគេរួមគ្នានឹងស្វែងរកលំហូរការងារដែលមានប្រសិទ្ធភាពបំផុត លឿនជាងក្រុមការងារផលិតផលណាមួយអាចរចនាពីលើចុះក្រោម (top-down) ទៅទៀត។ + +## ឯកសារ (Documentation) + +សម្រាប់ព័ត៌មានបន្ថែមអំពីរបៀបកំណត់រចនាសម្ព័ន្ធ cmux, [សូមចូលទៅកាន់ឯកសាររបស់យើង](https://cmux.dev/docs/getting-started?utm_source=readme)។ + +## គ្រាប់ចុចផ្លូវកាត់ (Keyboard Shortcuts) + +### តំបន់ការងារ (Workspaces) + +| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | +|---|---| +| ⌘ N | បង្កើត workspace ថ្មី | +| ⌘ 1–8 | លោតទៅ workspace ទី 1–8 | +| ⌘ 9 | លោតទៅ workspace ចុងក្រោយ | +| ⌃ ⌘ ] | workspace បន្ទាប់ | +| ⌃ ⌘ [ | workspace មុន | +| ⌘ ⇧ W | បិទ workspace | +| ⌘ ⇧ R | ប្តូរឈ្មោះ workspace | +| ⌘ B | បិទ/បើក របារចំហៀង | + +### ផ្ទៃ (Surfaces) + +| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | +|---|---| +| ⌘ T | បង្កើត surface ថ្មី | +| ⌘ ⇧ ] | surface បន្ទាប់ | +| ⌘ ⇧ [ | surface មុន | +| ⌃ Tab | surface បន្ទាប់ | +| ⌃ ⇧ Tab | surface មុន | +| ⌃ 1–8 | លោតទៅ surface ទី 1–8 | +| ⌃ 9 | លោតទៅ surface ចុងក្រោយ | +| ⌘ W | បិទ surface | + +### បំបែកផ្ទាំង (Split Panes) + +| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | +|---|---| +| ⌘ D | បំបែកទៅស្តាំ | +| ⌘ ⇧ D | បំបែកចុះក្រោម | +| ⌥ ⌘ ← → ↑ ↓ | ផ្ដោតលើ pane តាមទិសដៅ | +| ⌘ ⇧ H | បញ្ចេញពន្លឺលើ panel ដែលកំពុងផ្ដោត | + +### កម្មវិធីរុករក (Browser) + +ផ្លូវកាត់ឧបករណ៍អ្នកអភិវឌ្ឍន៍កម្មវិធីរុករក (Browser developer-tool shortcuts) ប្រើតាមលំនាំដើមរបស់ Safari ហើយអាចប្ដូរតាមបំណងបាននៅក្នុង `Settings → Keyboard Shortcuts`។ + +| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | +|---|---| +| ⌘ ⇧ L | បើកកម្មវិធីរុករកក្នុងលក្ខណៈបំបែក (split) | +| ⌘ L | ផ្ដោតលើរបារអាសយដ្ឋាន | +| ⌘ [ | ថយក្រោយ | +| ⌘ ] | ទៅមុខ | +| ⌘ R | ផ្ទុកទំព័រឡើងវិញ | +| ⌥ ⌘ I | បិទ/បើក ឧបករណ៍អ្នកអភិវឌ្ឍន៍ (លំនាំដើម Safari) | +| ⌥ ⌘ C | បង្ហាញ JavaScript Console (លំនាំដើម Safari) | + +### ការជូនដំណឹង (Notifications) + +| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | +|---|---| +| ⌘ I | បង្ហាញផ្ទាំងជូនដំណឹង | +| ⌘ ⇧ U | លោតទៅសារមិនទាន់អានថ្មីបំផុត | + +### ស្វែងរក (Find) + +| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | +|---|---| +| ⌘ F | ស្វែងរក | +| ⌘ G / ⌘ ⇧ G | ស្វែងរកបន្ទាប់ / មុន | +| ⌘ ⇧ F | លាក់របារស្វែងរក | +| ⌘ E | ប្រើអត្ថបទដែលបានជ្រើសរើសដើម្បីស្វែងរក | + +### Terminal + +| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | +|---|---| +| ⌘ K | សម្អាត scrollback | +| ⌘ C | ចម្លង (ជាមួយនឹងការជ្រើសរើស) | +| ⌘ V | ដាក់បញ្ចូល (Paste) | +| ⌘ + / ⌘ - | បង្កើន / បន្ថយ ទំហំអក្សរ | +| ⌘ 0 | កំណត់ទំហំអក្សរឡើងវិញ | + +### ផ្ទាំងវីនដូ (Window) + +| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | +|---|---| +| ⌘ ⇧ N | បង្កើតវីនដូថ្មី | +| ⌘ , | ការកំណត់ (Settings) | +| ⌘ ⇧ , | ផ្ទុកការកំណត់ឡើងវិញ (Reload configuration) | +| ⌘ Q | ចាកចេញ | + +## កំណែ Nightly Builds + +[ទាញយក cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY គឺជាកម្មវិធីដាច់ដោយឡែកមួយដែលមាន bundle ID ផ្ទាល់ខ្លួន ដូច្នេះវាអាចដំណើរការទន្ទឹមគ្នាជាមួយនឹងកំណែធម្មតា (stable version)។ វាត្រូវបានបង្កើតឡើងដោយស្វ័យប្រវត្តិពី commit `main` ចុងក្រោយបង្អស់ និងធ្វើបច្ចុប្បន្នភាពដោយស្វ័យប្រវត្តិតាមរយៈ Sparkle feed របស់វាផ្ទាល់។ + +## ការស្ដារ Session ឡើងវិញ (អាកប្បកិរិយាបច្ចុប្បន្ន) + +នៅពេលបើកឡើងវិញ បច្ចុប្បន្ន cmux នឹងស្ដារតែប្លង់កម្មវិធី និងទិន្នន័យមេតា (metadata) ប៉ុណ្ណោះ៖ + +* ប្លង់ Window/workspace/pane +* ថតការងារ (Working directories) +* Terminal scrollback (ប្រឹងប្រែងឱ្យអស់លទ្ធភាព) +* ប្រវត្តិរុករក និង URL របស់កម្មវិធីរុករក + +cmux **មិន** ស្ដារស្ថានភាពដំណើរការផ្ទាល់ (live process state) នៅក្នុងកម្មវិធី terminal ឡើយ។ ឧទាហរណ៍ session របស់ Claude Code/tmux/vim ដែលកំពុងដំណើរការ មិនទាន់អាចបន្តឡើងវិញបានទេបន្ទាប់ពីចាប់ផ្ដើមឡើងវិញ។ + +## Star History + +<a href="https://star-history.com/#manaflow-ai/cmux&Date"> + <picture> + <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" /> + <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" /> + <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" /> + </picture> +</a> + +## ការចូលរួមចំណែក (Contributing) + +វិធីក្នុងការចូលរួម៖ + +* តាមដានពួកយើងនៅលើ X សម្រាប់ការធ្វើបច្ចុប្បន្នភាពនានា [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), និង [@austinywang](https://x.com/austinywang) +* ចូលរួមការសន្ទនានៅលើ [Discord](https://discord.gg/xsgFEVrWCZ) +* បង្កើត និងចូលរួមក្នុង [GitHub issues](https://github.com/manaflow-ai/cmux/issues) និង [discussions](https://github.com/manaflow-ai/cmux/discussions) +* ប្រាប់ពួកយើងអំពីអ្វីដែលអ្នកកំពុងបង្កើតជាមួយ cmux + +## សហគមន៍ (Community) + +* [Discord](https://discord.gg/xsgFEVrWCZ) +* [GitHub](https://github.com/manaflow-ai/cmux) +* [X / Twitter](https://twitter.com/manaflowai) +* [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +* [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +* [Reddit](https://www.reddit.com/r/cmux/) + +## កំណែអ្នកស្ថាបនិក (Founder's Edition) + +cmux គឺឥតគិតថ្លៃ ជាកូដបើកចំហ (open source) និងតែងតែបែបនេះជារៀងរហូត។ ប្រសិនបើអ្នកចង់គាំទ្រដល់ការអភិវឌ្ឍន៍ និងទទួលបានសិទ្ធិប្រើប្រាស់មុខងារថ្មីៗមុនគេ (early access)៖ + +[**ទទួលបានកំណែអ្នកស្ថាបនិក (Get Founder's Edition)**](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q) + +* **ការស្នើសុំមុខងារ/ការជួសជុលកំហុសត្រូវបានផ្តល់អាទិភាព** +* **សិទ្ធិប្រើប្រាស់មុនគេ៖ cmux AI ដែលផ្តល់ឱ្យអ្នកនូវបរិបទ (context) លើរាល់ workspace, tab និង panel** +* **សិទ្ធិប្រើប្រាស់មុនគេ៖ កម្មវិធី iOS ដែលមាន terminal ធ្វើសមកាលកម្ម (synced) រវាងកុំព្យូទ័រ និងទូរស័ព្ទ** +* **សិទ្ធិប្រើប្រាស់មុនគេ៖ Cloud VMs** +* **សិទ្ធិប្រើប្រាស់មុនគេ៖ មុខងារសំឡេង (Voice mode)** +* **iMessage/WhatsApp ផ្ទាល់ខ្លួនរបស់ខ្ញុំ** + +## អាជ្ញាប័ណ្ណ (License) + +គម្រោងនេះត្រូវបានផ្តល់អាជ្ញាប័ណ្ណក្រោម GNU Affero General Public License v3.0 ឬក្រោយនេះ (`AGPL-3.0-or-later`)។ + +សូមមើលឯកសារ `LICENSE` សម្រាប់អត្ថបទពេញលេញ។ diff --git a/README.ko.md b/README.ko.md index 80ac5fde..9f36929b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,9 +1,7 @@ -> 이 번역은 Claude에 의해 생성되었습니다. 개선 사항이 있으면 PR을 제출해 주세요. - -<p align="center"><a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | 한국어 | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a></p> +> 이 문서는 Claude가 번역했어요. 개선할 부분이 있다면 PR을 보내주세요. <h1 align="center">cmux</h1> -<p align="center">AI 코딩 에이전트를 위한 세로 탭과 알림 기능을 갖춘 Ghostty 기반 macOS 터미널</p> +<p align="center">세로 탭과 알림을 지원하는 AI 코딩 에이전트용 Ghostty 기반 macOS 터미널</p> <p align="center"> <a href="https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg"> @@ -12,22 +10,69 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="cmux 스크린샷" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | 한국어 | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmux 스크린샷" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ 데모 영상</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## 기능 -- **세로 탭** — 사이드바에 git 브랜치, 작업 디렉토리, 리스닝 포트, 최신 알림 텍스트 표시 -- **알림 링** — AI 에이전트(Claude Code, OpenCode)가 사용자의 주의를 필요로 할 때 패널에 파란색 링이 표시되고 탭이 강조됨 -- **알림 패널** — 모든 대기 중인 알림을 한 곳에서 확인하고, 가장 최근의 읽지 않은 알림으로 바로 이동 -- **분할 패널** — 수평 및 수직 분할 지원 -- **내장 브라우저** — [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅된 스크립트 가능한 API를 갖춘 브라우저를 터미널 옆에 분할하여 사용 -- **스크립트 가능** — CLI와 socket API로 워크스페이스 생성, 패널 분할, 키 입력 전송, 브라우저 자동화 가능 -- **네이티브 macOS 앱** — Swift와 AppKit으로 구축, Electron이 아닙니다. 빠른 시작, 낮은 메모리 사용량. -- **Ghostty 호환** — 기존 `~/.config/ghostty/config`에서 테마, 글꼴, 색상 설정을 읽어옴 -- **GPU 가속** — libghostty로 구동되어 부드러운 렌더링 제공 +<table> +<tr> +<td width="40%" valign="middle"> +<h3>알림 링</h3> +코딩 에이전트가 입력을 기다리면 패널에 파란색 링이 뜨고 탭이 강조돼요 +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="알림 링" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>알림 패널</h3> +대기 중인 알림을 한곳에서 확인하고, 가장 최근 읽지 않은 알림으로 바로 이동할 수 있어요 +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="사이드바 알림 배지" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>내장 브라우저</h3> +<a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>에서 포팅된 스크립팅 API를 갖춘 브라우저를 터미널 옆에 띄울 수 있어요 +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="내장 브라우저" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>세로 + 가로 탭</h3> +사이드바에서 git 브랜치, 연결된 PR 상태/번호, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 한눈에 볼 수 있어요. 수평·수직 분할을 지원해요. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="세로 탭과 분할 패널" width="100%" /> +</td> +</tr> +</table> -## 설치 +- **스크립팅** — CLI와 socket API로 워크스페이스 생성, 패널 분할, 키 입력 전송, 브라우저 자동화가 가능해요 +- **네이티브 macOS 앱** — Electron이 아닌 Swift와 AppKit으로 만들었어요. 빠르게 실행되고 메모리도 적게 써요. +- **Ghostty 호환** — 기존 `~/.config/ghostty/config`에서 테마, 글꼴, 색상 설정을 그대로 읽어와요 +- **GPU 가속** — libghostty 기반이라 렌더링이 부드러워요 + +## 설치하기 ### DMG (권장) @@ -35,7 +80,7 @@ <img src="./docs/assets/macos-badge.png" alt="macOS용 cmux 다운로드" width="180" /> </a> -`.dmg` 파일을 열고 cmux를 응용 프로그램 폴더로 드래그하세요. cmux는 Sparkle을 통해 자동 업데이트되므로, 한 번만 다운로드하면 됩니다. +`.dmg` 파일을 열고 cmux를 응용 프로그램 폴더로 드래그하면 돼요. Sparkle을 통해 자동 업데이트되니 한 번만 다운로드하면 돼요. ### Homebrew @@ -44,25 +89,39 @@ brew tap manaflow-ai/cmux brew install --cask cmux ``` -나중에 업데이트하려면: +나중에 업데이트하려면 아래 명령어를 실행해주세요: ```bash brew upgrade --cask cmux ``` -처음 실행 시, macOS가 확인된 개발자의 앱을 여는 것을 확인하도록 요청할 수 있습니다. **열기**를 클릭하여 계속 진행하세요. +처음 실행할 때 macOS에서 개발자 확인 팝업이 뜰 수 있어요. **열기**를 클릭하면 돼요. ## 왜 cmux를 만들었나요? -저는 Claude Code와 Codex 세션을 대량으로 병렬 실행합니다. 이전에는 Ghostty에서 분할 패널을 여러 개 열어놓고, 에이전트가 저를 필요로 할 때 macOS 기본 알림에 의존했습니다. 하지만 Claude Code의 알림 내용은 항상 "Claude is waiting for your input"이라는 맥락 없는 동일한 메시지뿐이었고, 탭이 많아지면 제목조차 읽을 수 없었습니다. +저는 Claude Code와 Codex 세션을 여러 개 동시에 돌려요. 예전에는 Ghostty에서 분할 패널을 여러 개 열어놓고, 에이전트가 입력을 기다릴 때 macOS 기본 알림에 의존했어요. 그런데 Claude Code 알림은 항상 "Claude is waiting for your input"이라는 아무 맥락 없이 똑같은 메시지뿐이었고, 탭이 많아지면 제목조차 읽을 수가 없었어요. -몇 가지 코딩 오케스트레이터를 시도해봤지만, 대부분 Electron/Tauri 앱이어서 성능이 마음에 들지 않았습니다. 또한 GUI 오케스트레이터는 특정 워크플로우에 갇히게 되므로 터미널을 더 선호합니다. 그래서 Swift/AppKit으로 네이티브 macOS 앱인 cmux를 만들었습니다. 터미널 렌더링에 libghostty를 사용하고, 기존 Ghostty 설정에서 테마, 글꼴, 색상을 읽어옵니다. +여러 코딩 오케스트레이터를 써봤는데, 대부분 Electron/Tauri 앱이라 성능이 별로였어요. GUI 오케스트레이터는 특정 워크플로우에 갇히게 돼서 터미널이 더 낫다고 생각했고요. 그래서 Swift/AppKit으로 네이티브 macOS 앱인 cmux를 직접 만들었어요. 터미널 렌더링에는 libghostty를 쓰고, 기존 Ghostty 설정에서 테마, 글꼴, 색상을 그대로 가져와요. -주요 추가 기능은 사이드바와 알림 시스템입니다. 사이드바에는 각 워크스페이스의 git 브랜치, 작업 디렉토리, 리스닝 포트, 최신 알림 텍스트를 보여주는 세로 탭이 있습니다. 알림 시스템은 터미널 시퀀스(OSC 9/99/777)를 감지하고, Claude Code, OpenCode 등의 에이전트 훅에 연결할 수 있는 CLI(`cmux notify`)를 제공합니다. 에이전트가 대기 중일 때 해당 패널에 파란색 링이 표시되고 사이드바에서 탭이 강조되어, 여러 분할 패널과 탭에서 어떤 것이 저를 필요로 하는지 한눈에 알 수 있습니다. ⌘⇧U로 가장 최근의 읽지 않은 알림으로 이동합니다. +핵심은 사이드바와 알림 시스템이에요. 사이드바에는 각 워크스페이스의 git 브랜치, 연결된 PR 상태/번호, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 보여주는 세로 탭이 있어요. 알림 시스템은 터미널 시퀀스(OSC 9/99/777)를 감지하고, Claude Code나 OpenCode 같은 에이전트 훅에 연결할 수 있는 CLI(`cmux notify`)를 제공해요. 에이전트가 대기 중이면 해당 패널에 파란색 링이 뜨고 사이드바 탭이 강조되니까, 여러 패널과 탭 중에서 어디서 입력을 기다리는지 바로 알 수 있어요. ⌘⇧U를 누르면 가장 최근 읽지 않은 알림으로 이동해요. -내장 브라우저는 [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅된 스크립트 가능한 API를 갖추고 있습니다. 에이전트가 접근성 트리 스냅샷을 가져오고, 요소 참조를 얻고, 클릭하고, 양식을 작성하고, JS를 실행할 수 있습니다. 터미널 옆에 브라우저 패널을 분할하여 Claude Code가 개발 서버와 직접 상호작용하도록 할 수 있습니다. +내장 브라우저는 [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅한 스크립팅 API를 제공해요. 에이전트가 접근성 트리 스냅샷을 가져오고, 요소를 참조·클릭하고, 양식을 채우고, JS를 실행할 수 있어요. 터미널 옆에 브라우저 패널을 띄워서 Claude Code가 개발 서버와 직접 상호작용하게 할 수 있어요. -모든 것은 CLI와 socket API를 통해 스크립트 가능합니다 — 워크스페이스/탭 생성, 패널 분할, 키 입력 전송, 브라우저에서 URL 열기. +CLI와 socket API로 모든 걸 자동화할 수 있어요 — 워크스페이스/탭 생성, 패널 분할, 키 입력 전송, 브라우저에서 URL 열기까지요. + +## The Zen of cmux + +cmux는 개발자가 도구를 어떻게 사용해야 하는지 규정하지 않아요. 터미널과 브라우저에 CLI가 있고, 나머지는 여러분의 몫이에요. + +cmux는 솔루션이 아니라 프리미티브예요. 터미널, 브라우저, 알림, 워크스페이스, 분할, 탭, 그리고 이 모든 것을 제어하는 CLI를 제공해요. cmux는 코딩 에이전트를 특정 방식으로 사용하도록 강요하지 않아요. 프리미티브로 무엇을 만들지는 여러분에게 달려 있어요. + +최고의 개발자들은 항상 자신만의 도구를 만들어왔어요. 에이전트와 함께 일하는 최적의 방법은 아직 아무도 찾지 못했고, 폐쇄적인 제품을 만드는 팀들도 마찬가지예요. 자신의 코드베이스에 가장 가까운 개발자가 먼저 답을 찾을 거예요. + +100만 명의 개발자에게 조합 가능한 프리미티브를 주면, 어떤 프로덕트 팀이 위에서 설계하는 것보다 빠르게 가장 효율적인 워크플로우를 함께 찾아낼 거예요. + +## 문서 + +cmux 설정 방법에 대한 자세한 내용은 [문서를 확인해주세요](https://cmux.dev/docs/getting-started?utm_source=readme). ## 키보드 단축키 @@ -76,6 +135,7 @@ brew upgrade --cask cmux | ⌃ ⌘ ] | 다음 워크스페이스 | | ⌃ ⌘ [ | 이전 워크스페이스 | | ⌘ ⇧ W | 워크스페이스 닫기 | +| ⌘ ⇧ R | 워크스페이스 이름 변경 | | ⌘ B | 사이드바 토글 | ### 서피스 @@ -98,25 +158,28 @@ brew upgrade --cask cmux | ⌘ D | 오른쪽으로 분할 | | ⌘ ⇧ D | 아래로 분할 | | ⌥ ⌘ ← → ↑ ↓ | 방향키로 패널 포커스 이동 | -| ⌘ ⇧ H | 포커스된 패널 깜빡임 | +| ⌘ ⇧ H | 현재 패널 깜빡임 | ### 브라우저 +브라우저 개발자 도구 단축키는 Safari 기본값을 따르며, `설정 → 키보드 단축키`에서 변경할 수 있어요. + | 단축키 | 동작 | |----------|--------| -| ⌘ ⇧ L | 분할에서 브라우저 열기 | +| ⌘ ⇧ L | 분할 패널로 브라우저 열기 | | ⌘ L | 주소창 포커스 | | ⌘ [ | 뒤로 | | ⌘ ] | 앞으로 | | ⌘ R | 페이지 새로고침 | -| ⌥ ⌘ I | 개발자 도구 열기 | +| ⌥ ⌘ I | 개발자 도구 열기 (Safari 기본값) | +| ⌥ ⌘ C | JavaScript 콘솔 표시 (Safari 기본값) | ### 알림 | 단축키 | 동작 | |----------|--------| | ⌘ I | 알림 패널 표시 | -| ⌘ ⇧ U | 최신 읽지 않은 알림으로 이동 | +| ⌘ ⇧ U | 최근 읽지 않은 알림으로 이동 | ### 찾기 @@ -125,7 +188,7 @@ brew upgrade --cask cmux | ⌘ F | 찾기 | | ⌘ G / ⌘ ⇧ G | 다음 찾기 / 이전 찾기 | | ⌘ ⇧ F | 찾기 바 숨기기 | -| ⌘ E | 선택 영역으로 찾기 | +| ⌘ E | 선택한 텍스트로 찾기 | ### 터미널 @@ -146,8 +209,65 @@ brew upgrade --cask cmux | ⌘ ⇧ , | 설정 다시 불러오기 | | ⌘ Q | 종료 | +## 나이틀리 빌드 + +[cmux NIGHTLY 다운로드](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY는 자체 번들 ID를 가진 별도의 앱이라 안정 버전과 함께 실행할 수 있어요. 최신 `main` 커밋에서 자동으로 빌드되고, 자체 Sparkle 피드를 통해 자동 업데이트돼요. + +## 세션 복원 (현재 동작) + +재실행 시 cmux는 현재 앱 레이아웃과 메타데이터만 복원해요: +- 창/워크스페이스/패널 레이아웃 +- 작업 디렉토리 +- 터미널 스크롤백 (최선 노력) +- 브라우저 URL 및 탐색 기록 + +cmux는 터미널 앱 내부의 라이브 프로세스 상태를 복원하지 **않아요**. 예를 들어 활성 Claude Code/tmux/vim 세션은 재시작 후 아직 복원되지 않아요. + +## 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> + +## 기여하기 + +참여 방법: + +- X에서 팔로우해주세요: [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), [@austinywang](https://x.com/austinywang) +- [Discord](https://discord.gg/xsgFEVrWCZ)에서 대화에 참여해주세요 +- [GitHub Issues](https://github.com/manaflow-ai/cmux/issues)와 [토론](https://github.com/manaflow-ai/cmux/discussions)에 참여해주세요 +- cmux로 무엇을 만들고 있는지 알려주세요 + +## 커뮤니티 + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Founder's Edition + +cmux는 무료이고 오픈 소스이며, 앞으로도 그럴 거예요. 개발을 지원하고 다음에 나올 기능에 먼저 접근하고 싶다면: + +**[Founder's Edition 구매하기](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **기능 요청/버그 수정 우선 처리** +- **얼리 액세스: 모든 워크스페이스, 탭, 패널의 컨텍스트를 제공하는 cmux AI** +- **얼리 액세스: 데스크톱과 휴대폰 간 터미널을 동기화하는 iOS 앱** +- **얼리 액세스: 클라우드 VM** +- **얼리 액세스: 음성 모드** +- **저의 개인 iMessage/WhatsApp** + ## 라이선스 -이 프로젝트는 GNU Affero 일반 공중 사용 허가서 v3.0 이상(`AGPL-3.0-or-later`)에 따라 라이선스가 부여됩니다. +이 프로젝트는 GNU Affero General Public License v3.0 이상(`AGPL-3.0-or-later`)으로 배포돼요. -전체 라이선스 텍스트는 `LICENSE` 파일을 참조하세요. +자세한 내용은 `LICENSE` 파일을 확인해주세요. diff --git a/README.md b/README.md index 31578c73..599277e5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,12 @@ </p> <p align="center"> - English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> + English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> </p> <p align="center"> @@ -16,7 +21,7 @@ </p> <p align="center"> - <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo video</a> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo video</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Features @@ -52,7 +57,7 @@ Split a browser alongside your terminal with a scriptable API ported from <a hre <tr> <td width="40%" valign="middle"> <h3>Vertical + horizontal tabs</h3> -Sidebar shows git branch, working directory, listening ports, and latest notification text. Split horizontally and vertically. +Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically. </td> <td width="60%"> <img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertical tabs and split panes" width="100%" /> @@ -96,12 +101,26 @@ I run a lot of Claude Code and Codex sessions in parallel. I was using Ghostty w I tried a few coding orchestrators but most of them were Electron/Tauri apps and the performance bugged me. I also just prefer the terminal since GUI orchestrators lock you into their workflow. So I built cmux as a native macOS app in Swift/AppKit. It uses libghostty for terminal rendering and reads your existing Ghostty config for themes, fonts, and colors. -The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread. +The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, linked PR status/number, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread. The in-app browser has a scriptable API ported from [agent-browser](https://github.com/vercel-labs/agent-browser). Agents can snapshot the accessibility tree, get element refs, click, fill forms, and evaluate JS. You can split a browser pane next to your terminal and have Claude Code interact with your dev server directly. Everything is scriptable through the CLI and socket API — create workspaces/tabs, split panes, send keystrokes, open URLs in the browser. +## The Zen of cmux + +cmux is not prescriptive about how developers hold their tools. It's a terminal and browser with a CLI, and the rest is up to you. + +cmux is a primitive, not a solution. It gives you a terminal, a browser, notifications, workspaces, splits, tabs, and a CLI to control all of it. cmux doesn't force you into an opinionated way to use coding agents. What you build with the primitives is yours. + +The best developers have always built their own tools. Nobody has figured out the best way to work with agents yet, and the teams building closed products definitely haven't either. The developers closest to their own codebases will figure it out first. + +Give a million developers composable primitives and they'll collectively find the most efficient workflows faster than any product team could design top-down. + +## Documentation + +For more info on how to configure cmux, [head over to our docs](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Keyboard Shortcuts ### Workspaces @@ -114,6 +133,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,6 +213,35 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed. +## Session restore (current behavior) + +On relaunch, cmux currently restores app layout and metadata only: +- Window/workspace/pane layout +- Working directories +- Terminal scrollback (best effort) +- Browser URL and navigation history + +cmux does **not** restore live process state inside terminal apps. For example, active Claude Code/tmux/vim sessions are not resumed after restart yet. + +## Star History + +<a href="https://star-history.com/#manaflow-ai/cmux&Date"> + <picture> + <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" /> + <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" /> + <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" /> + </picture> +</a> + +## Contributing + +Ways to get involved: + +- Follow us on X for updates [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), and [@austinywang](https://x.com/austinywang) +- Join the conversation on [Discord](https://discord.gg/xsgFEVrWCZ) +- Create and participate in [GitHub issues](https://github.com/manaflow-ai/cmux/issues) and [discussions](https://github.com/manaflow-ai/cmux/discussions) +- Let us know what you're building with cmux + ## Community - [Discord](https://discord.gg/xsgFEVrWCZ) @@ -200,6 +249,20 @@ cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the - [X / Twitter](https://twitter.com/manaflowai) - [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) - [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Founder's Edition + +cmux is free, open source, and always will be. If you'd like to support development and get early access to what's coming next: + +**[Get Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Prioritized feature requests/bug fixes** +- **Early access: cmux AI that gives you context on every workspace, tab and panel** +- **Early access: iOS app with terminals synced between desktop and phone** +- **Early access: Cloud VMs** +- **Early access: Voice mode** +- **My personal iMessage/WhatsApp** ## License diff --git a/README.no.md b/README.no.md index 3ebec22c..fb7c211a 100644 --- a/README.no.md +++ b/README.no.md @@ -1,9 +1,5 @@ > Denne oversettelsen ble generert av Claude. Hvis du har forslag til forbedringer, send gjerne en PR. -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | Norsk | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">En Ghostty-basert macOS-terminal med vertikale faner og varsler for AI-kodeagenter</p> @@ -14,17 +10,64 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="cmux skjermbilde" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | Norsk | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmux skjermbilde" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demovideo</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Funksjoner -- **Vertikale faner** — Sidefeltet viser git-gren, arbeidsmappe, lyttende porter og siste varselstekst -- **Varselringer** — Paneler far en bla ring og faner lyser opp nar AI-agenter (Claude Code, OpenCode) trenger oppmerksomheten din -- **Varselpanel** — Se alle ventende varsler pa ett sted, hopp til det nyeste uleste -- **Delte paneler** — Horisontale og vertikale delinger -- **Innebygd nettleser** — Del en nettleser ved siden av terminalen med et skriptbart API portet fra [agent-browser](https://github.com/vercel-labs/agent-browser) -- **Skriptbar** — CLI og socket API for a opprette arbeidsomrader, dele paneler, sende tastetrykk og automatisere nettleseren +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Varselringer</h3> +Paneler får en blå ring og faner lyser opp når kodeagenter trenger oppmerksomheten din +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Varselringer" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Varselpanel</h3> +Se alle ventende varsler på ett sted, hopp til det nyeste uleste +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Varselmerke i sidefeltet" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Innebygd nettleser</h3> +Del en nettleser ved siden av terminalen med et skriptbart API portet fra <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Innebygd nettleser" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Vertikale + horisontale faner</h3> +Sidefeltet viser git-gren, tilknyttet PR-status/nummer, arbeidsmappe, lyttende porter og siste varselstekst. Del horisontalt og vertikalt. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertikale faner og delte paneler" width="100%" /> +</td> +</tr> +</table> + +- **Skriptbar** — CLI og socket API for å opprette arbeidsområder, dele paneler, sende tastetrykk og automatisere nettleseren - **Nativ macOS-app** — Bygget med Swift og AppKit, ikke Electron. Rask oppstart, lavt minneforbruk. - **Ghostty-kompatibel** — Leser din eksisterende `~/.config/ghostty/config` for temaer, skrifttyper og farger - **GPU-akselerert** — Drevet av libghostty for jevn gjengivelse @@ -37,7 +80,7 @@ <img src="./docs/assets/macos-badge.png" alt="Last ned cmux for macOS" width="180" /> </a> -Apne `.dmg`-filen og dra cmux til Programmer-mappen. cmux oppdaterer seg selv automatisk via Sparkle, sa du trenger bare a laste ned en gang. +Åpne `.dmg`-filen og dra cmux til Programmer-mappen. cmux oppdaterer seg selv automatisk via Sparkle, så du trenger bare å laste ned én gang. ### Homebrew @@ -46,38 +89,53 @@ brew tap manaflow-ai/cmux brew install --cask cmux ``` -For a oppdatere senere: +For å oppdatere senere: ```bash brew upgrade --cask cmux ``` -Ved forste oppstart kan macOS be deg bekrefte apning av en app fra en identifisert utvikler. Klikk **Apne** for a fortsette. +Ved første oppstart kan macOS be deg bekrefte åpning av en app fra en identifisert utvikler. Klikk **Åpne** for å fortsette. ## Hvorfor cmux? -Jeg kjorer mange Claude Code- og Codex-sesjoner parallelt. Jeg brukte Ghostty med en haug delte paneler, og stolte pa native macOS-varsler for a vite nar en agent trengte meg. Men Claude Codes varselstekst er alltid bare "Claude is waiting for your input" uten kontekst, og med nok faner apne kunne jeg ikke engang lese titlene lenger. +Jeg kjører mange Claude Code- og Codex-sesjoner parallelt. Jeg brukte Ghostty med en haug delte paneler, og stolte på native macOS-varsler for å vite når en agent trengte meg. Men Claude Codes varselstekst er alltid bare "Claude is waiting for your input" uten kontekst, og med nok faner åpne kunne jeg ikke engang lese titlene lenger. -Jeg provde noen kodeorkestratorer, men de fleste var Electron/Tauri-apper og ytelsen irriterte meg. Jeg foretrekker ogsa terminalen siden GUI-orkestratorer laser deg inn i arbeidsflyten deres. Sa jeg bygde cmux som en nativ macOS-app i Swift/AppKit. Den bruker libghostty for terminalgjengivelse og leser din eksisterende Ghostty-konfigurasjon for temaer, skrifttyper og farger. +Jeg prøvde noen kodeorkestratorer, men de fleste var Electron/Tauri-apper og ytelsen irriterte meg. Jeg foretrekker også terminalen siden GUI-orkestratorer låser deg inn i arbeidsflyten deres. Så jeg bygde cmux som en nativ macOS-app i Swift/AppKit. Den bruker libghostty for terminalgjengivelse og leser din eksisterende Ghostty-konfigurasjon for temaer, skrifttyper og farger. -Hovedtilleggene er sidefeltet og varselsystemet. Sidefeltet har vertikale faner som viser git-gren, arbeidsmappe, lyttende porter og siste varselstekst for hvert arbeidsomrade. Varselsystemet fanger opp terminalsekvenser (OSC 9/99/777) og har en CLI (`cmux notify`) du kan koble til agentkroker for Claude Code, OpenCode osv. Nar en agent venter, far panelet en bla ring og fanen lyser opp i sidefeltet, sa jeg kan se hvilken som trenger meg pa tvers av delinger og faner. Cmd+Shift+U hopper til det nyeste uleste. +Hovedtilleggene er sidefeltet og varselsystemet. Sidefeltet har vertikale faner som viser git-gren, tilknyttet PR-status/nummer, arbeidsmappe, lyttende porter og siste varselstekst for hvert arbeidsområde. Varselsystemet fanger opp terminalsekvenser (OSC 9/99/777) og har en CLI (`cmux notify`) du kan koble til agentkroker for Claude Code, OpenCode osv. Når en agent venter, får panelet en blå ring og fanen lyser opp i sidefeltet, så jeg kan se hvilken som trenger meg på tvers av delinger og faner. Cmd+Shift+U hopper til det nyeste uleste. -Den innebygde nettleseren har et skriptbart API portet fra [agent-browser](https://github.com/vercel-labs/agent-browser). Agenter kan ta overblikk over tilgjengelighetstreet, hente elementreferanser, klikke, fylle ut skjemaer og kjore JS. Du kan dele et nettleserpanel ved siden av terminalen og la Claude Code samhandle med utviklingsserveren din direkte. +Den innebygde nettleseren har et skriptbart API portet fra [agent-browser](https://github.com/vercel-labs/agent-browser). Agenter kan ta overblikk over tilgjengelighetstreet, hente elementreferanser, klikke, fylle ut skjemaer og kjøre JS. Du kan dele et nettleserpanel ved siden av terminalen og la Claude Code samhandle med utviklingsserveren din direkte. -Alt er skriptbart gjennom CLI og socket API — opprett arbeidsomrader/faner, del paneler, send tastetrykk, apne URLer i nettleseren. +Alt er skriptbart gjennom CLI og socket API — opprett arbeidsområder/faner, del paneler, send tastetrykk, åpne URLer i nettleseren. + +## The Zen of cmux + +cmux er ikke foreskrivende om hvordan utviklere bruker verktøyene sine. Det er en terminal og nettleser med en CLI, og resten er opp til deg. + +cmux er en primitiv, ikke en løsning. Det gir deg en terminal, en nettleser, varsler, arbeidsområder, delinger, faner og en CLI for å kontrollere alt sammen. cmux tvinger deg ikke inn i en bestemt måte å bruke kodeagenter på. Hva du bygger med primitivene er ditt. + +De beste utviklerne har alltid bygget sine egne verktøy. Ingen har funnet ut den beste måten å jobbe med agenter på ennå, og teamene som bygger lukkede produkter har definitivt ikke gjort det heller. Utviklerne som er nærmest sine egne kodebaser vil finne det ut først. + +Gi en million utviklere komponerbare primitiver og de vil kollektivt finne de mest effektive arbeidsflytene raskere enn noe produktteam kunne designet ovenfra og ned. + +## Dokumentasjon + +For mer informasjon om hvordan du konfigurerer cmux, [gå til dokumentasjonen vår](https://cmux.dev/docs/getting-started?utm_source=readme). ## Tastatursnarveier -### Arbeidsomrader +### Arbeidsområder | Snarvei | Handling | |----------|--------| -| ⌘ N | Nytt arbeidsomrade | -| ⌘ 1–8 | Hopp til arbeidsomrade 1–8 | -| ⌘ 9 | Hopp til siste arbeidsomrade | -| ⌃ ⌘ ] | Neste arbeidsomrade | -| ⌃ ⌘ [ | Forrige arbeidsomrade | -| ⌘ ⇧ W | Lukk arbeidsomrade | +| ⌘ N | Nytt arbeidsområde | +| ⌘ 1–8 | Hopp til arbeidsområde 1–8 | +| ⌘ 9 | Hopp til siste arbeidsområde | +| ⌃ ⌘ ] | Neste arbeidsområde | +| ⌃ ⌘ [ | Forrige arbeidsområde | +| ⌘ ⇧ W | Lukk arbeidsområde | +| ⌘ ⇧ R | Gi nytt navn til arbeidsområde | | ⌘ B | Vis/skjul sidefelt | ### Overflater @@ -97,21 +155,24 @@ Alt er skriptbart gjennom CLI og socket API — opprett arbeidsomrader/faner, de | Snarvei | Handling | |----------|--------| -| ⌘ D | Del til hoyre | +| ⌘ D | Del til høyre | | ⌘ ⇧ D | Del nedover | | ⌥ ⌘ ← → ↑ ↓ | Fokuser panel i retning | | ⌘ ⇧ H | Blink fokusert panel | ### Nettleser +Nettleserens utviklerverktøysnarveier følger Safari-standarder og kan tilpasses i `Innstillinger → Tastatursnarveier`. + | Snarvei | Handling | |----------|--------| -| ⌘ ⇧ L | Apne nettleser i deling | +| ⌘ ⇧ L | Åpne nettleser i deling | | ⌘ L | Fokuser adressefeltet | | ⌘ [ | Tilbake | | ⌘ ] | Fremover | -| ⌘ R | Last inn siden pa nytt | -| ⌥ ⌘ I | Apne utviklerverktoy | +| ⌘ R | Last inn siden på nytt | +| ⌥ ⌘ I | Vis/skjul utviklerverktøy (Safari-standard) | +| ⌥ ⌘ C | Vis JavaScript-konsoll (Safari-standard) | ### Varsler @@ -120,14 +181,14 @@ Alt er skriptbart gjennom CLI og socket API — opprett arbeidsomrader/faner, de | ⌘ I | Vis varselpanel | | ⌘ ⇧ U | Hopp til nyeste uleste | -### Sok +### Søk | Snarvei | Handling | |----------|--------| -| ⌘ F | Sok | -| ⌘ G / ⌘ ⇧ G | Sok neste / forrige | -| ⌘ ⇧ F | Skjul sokelinje | -| ⌘ E | Bruk utvalg til sok | +| ⌘ F | Søk | +| ⌘ G / ⌘ ⇧ G | Søk neste / forrige | +| ⌘ ⇧ F | Skjul søkelinje | +| ⌘ E | Bruk utvalg til søk | ### Terminal @@ -145,9 +206,66 @@ Alt er skriptbart gjennom CLI og socket API — opprett arbeidsomrader/faner, de |----------|--------| | ⌘ ⇧ N | Nytt vindu | | ⌘ , | Innstillinger | -| ⌘ ⇧ , | Last inn konfigurasjon pa nytt | +| ⌘ ⇧ , | Last inn konfigurasjon på nytt | | ⌘ Q | Avslutt | +## Nattlige bygg + +[Last ned cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY er en separat app med sin egen bundle-ID, så den kjører ved siden av den stabile versjonen. Bygges automatisk fra den siste `main`-commiten og oppdateres automatisk via sin egen Sparkle-feed. + +## Sesjonssgjenoppretting (nåværende oppførsel) + +Ved omstart gjenoppretter cmux for øyeblikket kun applayouten og metadata: +- Vindu-/arbeidsområde-/panellayout +- Arbeidsmapper +- Terminal-rullingshistorikk (best effort) +- Nettleser-URL og navigasjonshistorikk + +cmux gjenoppretter **ikke** aktive prosesstilstander inne i terminalapper. For eksempel blir aktive Claude Code/tmux/vim-sesjoner ikke gjenopptatt etter omstart ennå. + +## Stjernehistorikk + +<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> + +## Bidra + +Måter å engasjere seg: + +- Følg oss på X for oppdateringer [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), og [@austinywang](https://x.com/austinywang) +- Bli med i samtalen på [Discord](https://discord.gg/xsgFEVrWCZ) +- Opprett og delta i [GitHub-issues](https://github.com/manaflow-ai/cmux/issues) og [diskusjoner](https://github.com/manaflow-ai/cmux/discussions) +- Fortell oss hva du bygger med cmux + +## Fellesskap + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Grunnleggerutgaven + +cmux er gratis, åpen kildekode, og vil alltid være det. Hvis du vil støtte utviklingen og få tidlig tilgang til det som kommer: + +**[Få Grunnleggerutgaven](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Prioriterte funksjonsforespørsler/feilrettinger** +- **Tidlig tilgang: cmux AI som gir deg kontekst om hvert arbeidsområde, fane og panel** +- **Tidlig tilgang: iOS-app med terminaler synkronisert mellom desktop og telefon** +- **Tidlig tilgang: Sky-VMer** +- **Tidlig tilgang: Stemmemodus** +- **Min personlige iMessage/WhatsApp** + ## Lisens Dette prosjektet er lisensiert under GNU Affero General Public License v3.0 eller nyere (`AGPL-3.0-or-later`). diff --git a/README.pl.md b/README.pl.md index 1aa37f7f..3408897d 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1,9 +1,5 @@ > To tłumaczenie zostało wygenerowane przez Claude. Jeśli masz sugestie dotyczące poprawek, otwórz PR. -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | Polski | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">Terminal macOS oparty na Ghostty z pionowymi kartami i powiadomieniami dla agentów kodowania AI</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="Zrzut ekranu cmux" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | Polski | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="Zrzut ekranu cmux" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Film demonstracyjny</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Funkcje -- **Pionowe karty** — Pasek boczny pokazuje gałąź git, katalog roboczy, nasłuchujące porty i tekst ostatniego powiadomienia -- **Pierścienie powiadomień** — Panele otrzymują niebieski pierścień, a karty podświetlają się, gdy agenci AI (Claude Code, OpenCode) potrzebują Twojej uwagi -- **Panel powiadomień** — Zobacz wszystkie oczekujące powiadomienia w jednym miejscu, przeskocz do najnowszego nieprzeczytanego -- **Podzielone panele** — Podziały poziome i pionowe -- **Wbudowana przeglądarka** — Podziel przeglądarkę obok terminala ze skryptowalnym API przeniesionym z [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Pierścienie powiadomień</h3> +Panele otrzymują niebieski pierścień, a karty podświetlają się, gdy agenci kodowania potrzebują Twojej uwagi +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Pierścienie powiadomień" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Panel powiadomień</h3> +Zobacz wszystkie oczekujące powiadomienia w jednym miejscu, przeskocz do najnowszego nieprzeczytanego +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Znacznik powiadomień w pasku bocznym" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Wbudowana przeglądarka</h3> +Podziel przeglądarkę obok terminala ze skryptowalnym API przeniesionym z <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Wbudowana przeglądarka" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Pionowe + poziome karty</h3> +Pasek boczny pokazuje gałąź git, status/numer powiązanego PR, katalog roboczy, nasłuchujące porty i tekst ostatniego powiadomienia. Podziały poziome i pionowe. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Pionowe karty i podzielone panele" width="100%" /> +</td> +</tr> +</table> + - **Skryptowalny** — CLI i socket API do tworzenia przestrzeni roboczych, dzielenia paneli, wysyłania naciśnięć klawiszy i automatyzacji przeglądarki - **Natywna aplikacja macOS** — Zbudowana w Swift i AppKit, nie Electron. Szybki start, niskie zużycie pamięci. - **Kompatybilny z Ghostty** — Odczytuje istniejącą konfigurację `~/.config/ghostty/config` dla motywów, czcionek i kolorów @@ -60,12 +103,26 @@ Uruchamiam wiele sesji Claude Code i Codex równolegle. Używałem Ghostty z mas Wypróbowałem kilka orkiestratorów kodowania, ale większość z nich to aplikacje Electron/Tauri, a ich wydajność mi przeszkadzała. Po prostu wolę też terminal, ponieważ orkiestratory GUI zamykają cię w swoim przepływie pracy. Dlatego zbudowałem cmux jako natywną aplikację macOS w Swift/AppKit. Używa libghostty do renderowania terminala i odczytuje istniejącą konfigurację Ghostty dla motywów, czcionek i kolorów. -Główne dodatki to pasek boczny i system powiadomień. Pasek boczny ma pionowe karty pokazujące gałąź git, katalog roboczy, nasłuchujące porty i tekst ostatniego powiadomienia dla każdej przestrzeni roboczej. System powiadomień przechwytuje sekwencje terminala (OSC 9/99/777) i ma CLI (`cmux notify`), który można podpiąć do hooków agentów dla Claude Code, OpenCode itp. Gdy agent czeka, jego panel otrzymuje niebieski pierścień, a karta podświetla się w pasku bocznym, więc mogę powiedzieć, który mnie potrzebuje, niezależnie od podziałów i kart. Cmd+Shift+U przeskakuje do najnowszego nieprzeczytanego. +Główne dodatki to pasek boczny i system powiadomień. Pasek boczny ma pionowe karty pokazujące gałąź git, status/numer powiązanego PR, katalog roboczy, nasłuchujące porty i tekst ostatniego powiadomienia dla każdej przestrzeni roboczej. System powiadomień przechwytuje sekwencje terminala (OSC 9/99/777) i ma CLI (`cmux notify`), który można podpiąć do hooków agentów dla Claude Code, OpenCode itp. Gdy agent czeka, jego panel otrzymuje niebieski pierścień, a karta podświetla się w pasku bocznym, więc mogę powiedzieć, który mnie potrzebuje, niezależnie od podziałów i kart. Cmd+Shift+U przeskakuje do najnowszego nieprzeczytanego. Wbudowana przeglądarka ma skryptowalny API przeniesiony z [agent-browser](https://github.com/vercel-labs/agent-browser). Agenci mogą wykonać migawkę drzewa dostępności, uzyskać referencje elementów, klikać, wypełniać formularze i ewaluować JS. Możesz podzielić panel przeglądarki obok terminala i pozwolić Claude Code bezpośrednio komunikować się z Twoim serwerem deweloperskim. Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni roboczych/kart, dzielenie paneli, wysyłanie naciśnięć klawiszy, otwieranie URL-ów w przeglądarce. +## The Zen of cmux + +cmux nie narzuca programistom sposobu korzystania z narzędzi. To terminal i przeglądarka z CLI, a reszta zależy od Ciebie. + +cmux jest prymitywem, nie rozwiązaniem. Daje Ci terminal, przeglądarkę, powiadomienia, przestrzenie robocze, podziały, karty i CLI do kontrolowania tego wszystkiego. cmux nie zmusza Cię do określonego sposobu korzystania z agentów kodowania. To, co zbudujesz z tych prymitywów, jest Twoje. + +Najlepsi programiści zawsze budowali własne narzędzia. Nikt jeszcze nie wymyślił najlepszego sposobu pracy z agentami, a zespoły budujące zamknięte produkty też tego nie odkryły. Programiści najbliżej swoich własnych baz kodu wymyślą to pierwsi. + +Daj milionowi programistów kompozycyjne prymitywy, a wspólnie znajdą najefektywniejsze przepływy pracy szybciej, niż jakikolwiek zespół produktowy mógłby zaprojektować odgórnie. + +## Dokumentacja + +Więcej informacji o konfiguracji cmux znajdziesz w [naszej dokumentacji](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Skróty Klawiszowe ### Przestrzenie robocze @@ -78,6 +135,7 @@ Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni robo | ⌃ ⌘ ] | Następna przestrzeń robocza | | ⌃ ⌘ [ | Poprzednia przestrzeń robocza | | ⌘ ⇧ W | Zamknij przestrzeń roboczą | +| ⌘ ⇧ R | Zmień nazwę przestrzeni roboczej | | ⌘ B | Przełącz pasek boczny | ### Powierzchnie @@ -104,6 +162,8 @@ Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni robo ### Przeglądarka +Skróty narzędzi deweloperskich przeglądarki odpowiadają domyślnym ustawieniom Safari i można je dostosować w `Ustawienia → Skróty klawiszowe`. + | Skrót | Akcja | |----------|--------| | ⌘ ⇧ L | Otwórz przeglądarkę w podziale | @@ -111,7 +171,8 @@ Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni robo | ⌘ [ | Wstecz | | ⌘ ] | Do przodu | | ⌘ R | Przeładuj stronę | -| ⌥ ⌘ I | Otwórz Narzędzia Deweloperskie | +| ⌥ ⌘ I | Przełącz Narzędzia Deweloperskie (domyślne Safari) | +| ⌥ ⌘ C | Pokaż Konsolę JavaScript (domyślne Safari) | ### Powiadomienia @@ -148,6 +209,63 @@ Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni robo | ⌘ ⇧ , | Przeładuj konfigurację | | ⌘ Q | Zakończ | +## Wersje Nightly + +[Pobierz cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY to osobna aplikacja z własnym identyfikatorem pakietu, więc działa obok wersji stabilnej. Budowana automatycznie z najnowszego commitu `main` i aktualizuje się automatycznie przez własny kanał Sparkle. + +## Przywracanie sesji (obecne zachowanie) + +Przy ponownym uruchomieniu cmux obecnie przywraca tylko układ aplikacji i metadane: +- Układ okien/przestrzeni roboczych/paneli +- Katalogi robocze +- Scrollback terminala (najlepsza próba) +- URL przeglądarki i historia nawigacji + +cmux **nie** przywraca stanu żywych procesów wewnątrz aplikacji terminalowych. Na przykład aktywne sesje Claude Code/tmux/vim nie są jeszcze wznawiane po restarcie. + +## Historia Gwiazdek + +<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> + +## Współtworzenie + +Sposoby zaangażowania się: + +- Obserwuj nas na X po aktualizacje [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) i [@austinywang](https://x.com/austinywang) +- Dołącz do rozmowy na [Discordzie](https://discord.gg/xsgFEVrWCZ) +- Twórz i uczestniczaj w [zgłoszeniach GitHub](https://github.com/manaflow-ai/cmux/issues) i [dyskusjach](https://github.com/manaflow-ai/cmux/discussions) +- Daj nam znać, co budujesz z cmux + +## Społeczność + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Edycja Założycielska + +cmux jest darmowy, open source i zawsze taki będzie. Jeśli chcesz wesprzeć rozwój i uzyskać wczesny dostęp do nadchodzących funkcji: + +**[Zdobądź Edycję Założycielską](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Priorytetowe prośby o funkcje/poprawki błędów** +- **Wczesny dostęp: cmux AI, które daje Ci kontekst każdej przestrzeni roboczej, karty i panelu** +- **Wczesny dostęp: aplikacja iOS z terminalami synchronizowanymi między komputerem a telefonem** +- **Wczesny dostęp: maszyny wirtualne w chmurze** +- **Wczesny dostęp: tryb głosowy** +- **Mój osobisty iMessage/WhatsApp** + ## Licencja Ten projekt jest licencjonowany na warunkach GNU Affero General Public License v3.0 lub nowszej (`AGPL-3.0-or-later`). diff --git a/README.pt-BR.md b/README.pt-BR.md index cc1767ba..f815f276 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -1,9 +1,5 @@ > Esta tradução foi gerada pelo Claude. Se você tiver sugestões de melhoria, abra um PR. -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | Português (Brasil) | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">Um terminal macOS baseado em Ghostty com abas verticais e notificações para agentes de programação com IA</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="Captura de tela do cmux" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | Português (Brasil) | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="Captura de tela do cmux" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Vídeo de demonstração</a> · <a href="https://cmux.dev/blog/zen-of-cmux">O Zen do cmux</a> </p> ## Recursos -- **Abas verticais** — A barra lateral mostra o branch do git, diretório de trabalho, portas em escuta e o texto da última notificação -- **Anéis de notificação** — Os painéis recebem um anel azul e as abas acendem quando agentes de IA (Claude Code, OpenCode) precisam da sua atenção -- **Painel de notificações** — Veja todas as notificações pendentes em um só lugar, vá direto para a mais recente não lida -- **Painéis divididos** — Divisões horizontais e verticais -- **Navegador integrado** — Divida um navegador ao lado do seu terminal com uma API programável portada do [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Anéis de notificação</h3> +Os painéis recebem um anel azul e as abas acendem quando agentes de programação precisam da sua atenção +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Anéis de notificação" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Painel de notificações</h3> +Veja todas as notificações pendentes em um só lugar, vá direto para a mais recente não lida +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Badge de notificação na barra lateral" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Navegador integrado</h3> +Divida um navegador ao lado do seu terminal com uma API programável portada do <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Navegador integrado" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Abas verticais + horizontais</h3> +A barra lateral mostra o branch do git, status/número do PR vinculado, diretório de trabalho, portas em escuta e texto da última notificação. Divida horizontal e verticalmente. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Abas verticais e painéis divididos" width="100%" /> +</td> +</tr> +</table> + - **Programável** — CLI e socket API para criar workspaces, dividir painéis, enviar teclas e automatizar o navegador - **App nativo macOS** — Construído com Swift e AppKit, não Electron. Inicialização rápida, baixo consumo de memória. - **Compatível com Ghostty** — Lê sua configuração existente em `~/.config/ghostty/config` para temas, fontes e cores @@ -60,15 +103,29 @@ Eu executo muitas sessões de Claude Code e Codex em paralelo. Eu estava usando Eu tentei alguns orquestradores de código, mas a maioria era apps Electron/Tauri e o desempenho me incomodava. Eu também prefiro o terminal, já que orquestradores GUI te prendem no fluxo de trabalho deles. Então eu construí o cmux como um app nativo macOS em Swift/AppKit. Ele usa o libghostty para renderização do terminal e lê sua configuração existente do Ghostty para temas, fontes e cores. -As principais adições são a barra lateral e o sistema de notificações. A barra lateral tem abas verticais que mostram o branch do git, diretório de trabalho, portas em escuta e o texto da última notificação para cada workspace. O sistema de notificações captura sequências do terminal (OSC 9/99/777) e tem uma CLI (`cmux notify`) que você pode conectar aos hooks de agentes para Claude Code, OpenCode, etc. Quando um agente está esperando, seu painel recebe um anel azul e a aba acende na barra lateral, para que eu possa ver qual precisa de mim entre divisões e abas. Cmd+Shift+U pula para o mais recente não lido. +As principais adições são a barra lateral e o sistema de notificações. A barra lateral tem abas verticais que mostram o branch do git, status/número do PR vinculado, diretório de trabalho, portas em escuta e o texto da última notificação para cada workspace. O sistema de notificações captura sequências do terminal (OSC 9/99/777) e tem uma CLI (`cmux notify`) que você pode conectar aos hooks de agentes para Claude Code, OpenCode, etc. Quando um agente está esperando, seu painel recebe um anel azul e a aba acende na barra lateral, para que eu possa ver qual precisa de mim entre divisões e abas. Cmd+Shift+U pula para o mais recente não lido. O navegador integrado tem uma API programável portada do [agent-browser](https://github.com/vercel-labs/agent-browser). Agentes podem capturar a árvore de acessibilidade, obter referências de elementos, clicar, preencher formulários e executar JS. Você pode dividir um painel de navegador ao lado do seu terminal e fazer o Claude Code interagir diretamente com seu servidor de desenvolvimento. Tudo é programável através da CLI e socket API — criar workspaces/abas, dividir painéis, enviar teclas, abrir URLs no navegador. +## O Zen do cmux + +O cmux não é prescritivo sobre como os desenvolvedores usam suas ferramentas. É um terminal e navegador com uma CLI, e o resto é com você. + +O cmux é uma primitiva, não uma solução. Ele te dá um terminal, um navegador, notificações, workspaces, divisões, abas e uma CLI para controlar tudo isso. O cmux não te força a usar agentes de programação de uma forma específica. O que você constrói com as primitivas é seu. + +Os melhores desenvolvedores sempre construíram suas próprias ferramentas. Ninguém descobriu ainda a melhor forma de trabalhar com agentes, e as equipes construindo produtos fechados definitivamente também não. Os desenvolvedores mais próximos de suas próprias bases de código vão descobrir primeiro. + +Dê a um milhão de desenvolvedores primitivas combináveis e eles coletivamente encontrarão os fluxos de trabalho mais eficientes mais rápido do que qualquer equipe de produto poderia projetar de cima para baixo. + +## Documentação + +Para mais informações sobre como configurar o cmux, [acesse nossa documentação](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Atalhos de Teclado -### Workspaces +### Áreas de Trabalho | Atalho | Ação | |----------|--------| @@ -78,9 +135,10 @@ Tudo é programável através da CLI e socket API — criar workspaces/abas, div | ⌃ ⌘ ] | Próximo workspace | | ⌃ ⌘ [ | Workspace anterior | | ⌘ ⇧ W | Fechar workspace | +| ⌘ ⇧ R | Renomear workspace | | ⌘ B | Alternar barra lateral | -### Surfaces +### Superfícies | Atalho | Ação | |----------|--------| @@ -104,6 +162,8 @@ Tudo é programável através da CLI e socket API — criar workspaces/abas, div ### Navegador +Os atalhos de ferramentas do desenvolvedor do navegador seguem os padrões do Safari e podem ser personalizados em `Configurações → Atalhos de Teclado`. + | Atalho | Ação | |----------|--------| | ⌘ ⇧ L | Abrir navegador em divisão | @@ -111,7 +171,8 @@ Tudo é programável através da CLI e socket API — criar workspaces/abas, div | ⌘ [ | Voltar | | ⌘ ] | Avançar | | ⌘ R | Recarregar página | -| ⌥ ⌘ I | Abrir Ferramentas do Desenvolvedor | +| ⌥ ⌘ I | Alternar Ferramentas do Desenvolvedor (padrão Safari) | +| ⌥ ⌘ C | Mostrar Console JavaScript (padrão Safari) | ### Notificações @@ -148,6 +209,63 @@ Tudo é programável através da CLI e socket API — criar workspaces/abas, div | ⌘ ⇧ , | Recarregar configuração | | ⌘ Q | Sair | +## Builds Noturnos + +[Baixar cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +O cmux NIGHTLY é um app separado com seu próprio bundle ID, então roda ao lado da versão estável. Construído automaticamente a partir do último commit em `main` e se atualiza automaticamente via seu próprio feed Sparkle. + +## Restauração de sessão (comportamento atual) + +Ao reiniciar, o cmux atualmente restaura apenas o layout do app e metadados: +- Layout de janelas/workspaces/painéis +- Diretórios de trabalho +- Histórico de rolagem do terminal (melhor esforço) +- URL do navegador e histórico de navegação + +O cmux **não** restaura o estado de processos ativos dentro de apps de terminal. Por exemplo, sessões ativas de Claude Code/tmux/vim não são retomadas após reiniciar ainda. + +## Histórico de Estrelas + +<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> + +## Contribuindo + +Formas de participar: + +- Siga-nos no X para atualizações [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), e [@austinywang](https://x.com/austinywang) +- Participe da conversa no [Discord](https://discord.gg/xsgFEVrWCZ) +- Crie e participe de [issues no GitHub](https://github.com/manaflow-ai/cmux/issues) e [discussões](https://github.com/manaflow-ai/cmux/discussions) +- Nos conte o que você está construindo com o cmux + +## Comunidade + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Edição do Fundador + +O cmux é gratuito, open source, e sempre será. Se você gostaria de apoiar o desenvolvimento e ter acesso antecipado ao que está por vir: + +**[Obter Edição do Fundador](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Solicitações de recursos/correções de bugs priorizadas** +- **Acesso antecipado: cmux AI que te dá contexto sobre cada workspace, aba e painel** +- **Acesso antecipado: app iOS com terminais sincronizados entre desktop e celular** +- **Acesso antecipado: VMs na nuvem** +- **Acesso antecipado: Modo de voz** +- **Meu iMessage/WhatsApp pessoal** + ## Licença Este projeto é licenciado sob a GNU Affero General Public License v3.0 ou posterior (`AGPL-3.0-or-later`). diff --git a/README.ru.md b/README.ru.md index 328a5163..78769516 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,9 +1,5 @@ > Этот перевод создан Claude. Если у вас есть предложения по улучшению, откройте PR. -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | Русский | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">Терминал macOS на базе Ghostty с вертикальными вкладками и уведомлениями для AI-агентов программирования</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="Скриншот cmux" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | Русский | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="Скриншот cmux" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Демо-видео</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Возможности -- **Вертикальные вкладки** — Боковая панель показывает ветку git, рабочий каталог, прослушиваемые порты и текст последнего уведомления -- **Кольца уведомлений** — Панели получают синее кольцо, а вкладки подсвечиваются, когда AI-агенты (Claude Code, OpenCode) нуждаются в вашем внимании -- **Панель уведомлений** — Просматривайте все ожидающие уведомления в одном месте, переходите к последнему непрочитанному -- **Разделённые панели** — Горизонтальное и вертикальное разделение -- **Встроенный браузер** — Разделите браузер рядом с терминалом со скриптуемым API, портированным из [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Кольца уведомлений</h3> +Панели получают синее кольцо, а вкладки подсвечиваются, когда агенты программирования нуждаются в вашем внимании +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Кольца уведомлений" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Панель уведомлений</h3> +Просматривайте все ожидающие уведомления в одном месте, переходите к последнему непрочитанному +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Значок уведомлений в боковой панели" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Встроенный браузер</h3> +Разделите браузер рядом с терминалом со скриптуемым API, портированным из <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Встроенный браузер" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Вертикальные + горизонтальные вкладки</h3> +Боковая панель показывает ветку git, статус/номер связанного PR, рабочий каталог, прослушиваемые порты и текст последнего уведомления. Горизонтальное и вертикальное разделение. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Вертикальные вкладки и разделённые панели" width="100%" /> +</td> +</tr> +</table> + - **Скриптуемость** — CLI и socket API для создания рабочих пространств, разделения панелей, отправки нажатий клавиш и автоматизации браузера - **Нативное приложение macOS** — Создано на Swift и AppKit, не Electron. Быстрый запуск, низкое потребление памяти. - **Совместимость с Ghostty** — Читает вашу существующую конфигурацию `~/.config/ghostty/config` для тем, шрифтов и цветов @@ -60,12 +103,26 @@ brew upgrade --cask cmux Я попробовал несколько оркестраторов для кодирования, но большинство из них были приложениями Electron/Tauri, и их производительность меня раздражала. К тому же я просто предпочитаю терминал, поскольку GUI-оркестраторы привязывают вас к своему рабочему процессу. Поэтому я создал cmux как нативное приложение macOS на Swift/AppKit. Оно использует libghostty для рендеринга терминала и читает вашу существующую конфигурацию Ghostty для тем, шрифтов и цветов. -Основные дополнения — это боковая панель и система уведомлений. Боковая панель имеет вертикальные вкладки, которые показывают ветку git, рабочий каталог, прослушиваемые порты и текст последнего уведомления для каждого рабочего пространства. Система уведомлений перехватывает терминальные последовательности (OSC 9/99/777) и имеет CLI (`cmux notify`), который можно подключить к хукам агентов для Claude Code, OpenCode и т.д. Когда агент ожидает, его панель получает синее кольцо, а вкладка подсвечивается в боковой панели, так что я могу определить, какой из них нуждается во мне, среди разделений и вкладок. Cmd+Shift+U переходит к последнему непрочитанному. +Основные дополнения — это боковая панель и система уведомлений. Боковая панель имеет вертикальные вкладки, которые показывают ветку git, статус/номер связанного PR, рабочий каталог, прослушиваемые порты и текст последнего уведомления для каждого рабочего пространства. Система уведомлений перехватывает терминальные последовательности (OSC 9/99/777) и имеет CLI (`cmux notify`), который можно подключить к хукам агентов для Claude Code, OpenCode и т.д. Когда агент ожидает, его панель получает синее кольцо, а вкладка подсвечивается в боковой панели, так что я могу определить, какой из них нуждается во мне, среди разделений и вкладок. Cmd+Shift+U переходит к последнему непрочитанному. Встроенный браузер имеет скриптуемый API, портированный из [agent-browser](https://github.com/vercel-labs/agent-browser). Агенты могут делать снимок дерева доступности, получать ссылки на элементы, кликать, заполнять формы и выполнять JS. Вы можете разделить панель браузера рядом с терминалом и позволить Claude Code взаимодействовать с вашим сервером разработки напрямую. Всё скриптуемо через CLI и socket API — создание рабочих пространств/вкладок, разделение панелей, отправка нажатий клавиш, открытие URL в браузере. +## The Zen of cmux + +cmux не навязывает разработчикам, как использовать свои инструменты. Это терминал и браузер с CLI, а остальное зависит от вас. + +cmux — это примитив, а не решение. Он даёт вам терминал, браузер, уведомления, рабочие пространства, разделения, вкладки и CLI для управления всем этим. cmux не заставляет вас использовать агентов для кодирования определённым образом. То, что вы построите из этих примитивов, принадлежит вам. + +Лучшие разработчики всегда создавали собственные инструменты. Никто ещё не нашёл лучший способ работы с агентами, и команды, создающие закрытые продукты, тоже этого не сделали. Разработчики, ближе всех к своим кодовым базам, найдут это первыми. + +Дайте миллиону разработчиков композируемые примитивы, и они коллективно найдут наиболее эффективные рабочие процессы быстрее, чем любая продуктовая команда могла бы спроектировать сверху вниз. + +## Документация + +Подробнее о настройке cmux читайте в [нашей документации](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Сочетания Клавиш ### Рабочие пространства @@ -78,6 +135,7 @@ brew upgrade --cask cmux | ⌃ ⌘ ] | Следующее рабочее пространство | | ⌃ ⌘ [ | Предыдущее рабочее пространство | | ⌘ ⇧ W | Закрыть рабочее пространство | +| ⌘ ⇧ R | Переименовать рабочее пространство | | ⌘ B | Переключить боковую панель | ### Поверхности @@ -104,6 +162,8 @@ brew upgrade --cask cmux ### Браузер +Сочетания клавиш инструментов разработчика браузера соответствуют настройкам Safari по умолчанию и настраиваются в `Настройки → Сочетания клавиш`. + | Сочетание | Действие | |----------|--------| | ⌘ ⇧ L | Открыть браузер в разделении | @@ -111,7 +171,8 @@ brew upgrade --cask cmux | ⌘ [ | Назад | | ⌘ ] | Вперёд | | ⌘ R | Перезагрузить страницу | -| ⌥ ⌘ I | Открыть Инструменты Разработчика | +| ⌥ ⌘ I | Переключить Инструменты Разработчика (по умолчанию Safari) | +| ⌥ ⌘ C | Показать Консоль JavaScript (по умолчанию Safari) | ### Уведомления @@ -148,6 +209,63 @@ brew upgrade --cask cmux | ⌘ ⇧ , | Перезагрузить конфигурацию | | ⌘ Q | Выход | +## Ночные сборки + +[Скачать cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY — это отдельное приложение с собственным идентификатором пакета, поэтому оно работает параллельно со стабильной версией. Собирается автоматически из последнего коммита `main` и обновляется через собственный канал Sparkle. + +## Восстановление сессии (текущее поведение) + +При перезапуске cmux в настоящее время восстанавливает только макет приложения и метаданные: +- Макет окон/рабочих пространств/панелей +- Рабочие каталоги +- Scrollback терминала (по возможности) +- URL браузера и история навигации + +cmux **не** восстанавливает состояние живых процессов внутри терминальных приложений. Например, активные сессии Claude Code/tmux/vim пока не возобновляются после перезапуска. + +## История звёзд + +<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> + +## Участие + +Способы принять участие: + +- Подписывайтесь на нас в X для получения обновлений [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) и [@austinywang](https://x.com/austinywang) +- Присоединяйтесь к обсуждению в [Discord](https://discord.gg/xsgFEVrWCZ) +- Создавайте и участвуйте в [GitHub issues](https://github.com/manaflow-ai/cmux/issues) и [обсуждениях](https://github.com/manaflow-ai/cmux/discussions) +- Расскажите нам, что вы создаёте с помощью cmux + +## Сообщество + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Издание основателя + +cmux бесплатен, с открытым исходным кодом и всегда будет таким. Если вы хотите поддержать разработку и получить ранний доступ к будущим возможностям: + +**[Получить Издание основателя](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Приоритетные запросы на функции/исправления ошибок** +- **Ранний доступ: cmux AI, который даёт контекст по каждому рабочему пространству, вкладке и панели** +- **Ранний доступ: приложение для iOS с терминалами, синхронизированными между компьютером и телефоном** +- **Ранний доступ: облачные виртуальные машины** +- **Ранний доступ: голосовой режим** +- **Мой личный iMessage/WhatsApp** + ## Лицензия Этот проект лицензирован под GNU Affero General Public License v3.0 или более поздней версии (`AGPL-3.0-or-later`). diff --git a/README.th.md b/README.th.md index f8c6155b..d57fe8a8 100644 --- a/README.th.md +++ b/README.th.md @@ -1,9 +1,5 @@ > การแปลนี้สร้างโดย Claude หากมีข้อเสนอแนะในการปรับปรุง กรุณาเปิด PR -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | ไทย | <a href="README.tr.md">Türkçe</a> -</p> - <h1 align="center">cmux</h1> <p align="center">เทอร์มินัล macOS ที่ใช้ Ghostty พร้อมแท็บแนวตั้งและการแจ้งเตือนสำหรับเอเจนต์เขียนโค้ด AI</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="ภาพหน้าจอ cmux" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | ไทย | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="ภาพหน้าจอ cmux" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ วิดีโอสาธิต</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## คุณสมบัติ -- **แท็บแนวตั้ง** — แถบด้านข้างแสดง git branch, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุด -- **วงแหวนแจ้งเตือน** — แผงจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นเมื่อเอเจนต์ AI (Claude Code, OpenCode) ต้องการความสนใจของคุณ -- **แผงแจ้งเตือน** — ดูการแจ้งเตือนที่รอดำเนินการทั้งหมดในที่เดียว ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด -- **แผงแบ่ง** — แบ่งแนวนอนและแนวตั้ง -- **เบราว์เซอร์ในแอป** — แบ่งเบราว์เซอร์ข้างเทอร์มินัลพร้อม API ที่เขียนสคริปต์ได้ ย้ายมาจาก [agent-browser](https://github.com/vercel-labs/agent-browser) +<table> +<tr> +<td width="40%" valign="middle"> +<h3>วงแหวนแจ้งเตือน</h3> +แผงจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นเมื่อเอเจนต์เขียนโค้ดต้องการความสนใจของคุณ +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="วงแหวนแจ้งเตือน" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>แผงแจ้งเตือน</h3> +ดูการแจ้งเตือนที่รอดำเนินการทั้งหมดในที่เดียว ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="ป้ายแจ้งเตือนแถบด้านข้าง" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>เบราว์เซอร์ในแอป</h3> +แบ่งเบราว์เซอร์ข้างเทอร์มินัลพร้อม API ที่เขียนสคริปต์ได้ ย้ายมาจาก <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="เบราว์เซอร์ในตัว" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>แท็บแนวตั้ง + แนวนอน</h3> +แถบด้านข้างแสดง git branch, สถานะ/หมายเลข PR ที่เชื่อมโยง, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุด แบ่งแนวนอนและแนวตั้ง +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="แท็บแนวตั้งและแผงแบ่ง" width="100%" /> +</td> +</tr> +</table> + - **เขียนสคริปต์ได้** — CLI และ socket API สำหรับสร้างเวิร์กสเปซ แบ่งแผง ส่งการกดแป้นพิมพ์ และควบคุมเบราว์เซอร์อัตโนมัติ - **แอป macOS ดั้งเดิม** — สร้างด้วย Swift และ AppKit ไม่ใช่ Electron เริ่มต้นเร็ว ใช้หน่วยความจำน้อย - **เข้ากันได้กับ Ghostty** — อ่านการตั้งค่าที่มีอยู่ของคุณจาก `~/.config/ghostty/config` สำหรับธีม ฟอนต์ และสี @@ -60,12 +103,26 @@ brew upgrade --cask cmux ผมลองใช้ออร์เคสเตรเตอร์สำหรับเขียนโค้ดบางตัว แต่ส่วนใหญ่เป็นแอป Electron/Tauri และประสิทธิภาพทำให้ผมรำคาญ ผมยังชอบเทอร์มินัลมากกว่าเพราะออร์เคสเตรเตอร์ GUI บังคับให้คุณใช้เวิร์กโฟลว์ของมัน ผมจึงสร้าง cmux เป็นแอป macOS ดั้งเดิมด้วย Swift/AppKit มันใช้ libghostty สำหรับการแสดงผลเทอร์มินัลและอ่านการตั้งค่า Ghostty ที่มีอยู่ของคุณสำหรับธีม ฟอนต์ และสี -สิ่งที่เพิ่มเติมหลักคือแถบด้านข้างและระบบแจ้งเตือน แถบด้านข้างมีแท็บแนวตั้งที่แสดง git branch, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุดสำหรับแต่ละเวิร์กสเปซ ระบบแจ้งเตือนจับลำดับเทอร์มินัล (OSC 9/99/777) และมี CLI (`cmux notify`) ที่คุณสามารถเชื่อมต่อกับ hook ของเอเจนต์สำหรับ Claude Code, OpenCode เป็นต้น เมื่อเอเจนต์กำลังรอ แผงของมันจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นในแถบด้านข้าง เพื่อให้ผมบอกได้ว่าอันไหนต้องการผมข้ามแผงแบ่งและแท็บต่าง ๆ Cmd+Shift+U ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด +สิ่งที่เพิ่มเติมหลักคือแถบด้านข้างและระบบแจ้งเตือน แถบด้านข้างมีแท็บแนวตั้งที่แสดง git branch, สถานะ/หมายเลข PR ที่เชื่อมโยง, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุดสำหรับแต่ละเวิร์กสเปซ ระบบแจ้งเตือนจับลำดับเทอร์มินัล (OSC 9/99/777) และมี CLI (`cmux notify`) ที่คุณสามารถเชื่อมต่อกับ hook ของเอเจนต์สำหรับ Claude Code, OpenCode เป็นต้น เมื่อเอเจนต์กำลังรอ แผงของมันจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นในแถบด้านข้าง เพื่อให้ผมบอกได้ว่าอันไหนต้องการผมข้ามแผงแบ่งและแท็บต่าง ๆ Cmd+Shift+U ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด เบราว์เซอร์ในแอปมี API ที่เขียนสคริปต์ได้ ย้ายมาจาก [agent-browser](https://github.com/vercel-labs/agent-browser) เอเจนต์สามารถจับภาพ accessibility tree, รับ element refs, คลิก, กรอกฟอร์ม และรัน JS ได้ คุณสามารถแบ่งแผงเบราว์เซอร์ข้างเทอร์มินัลและให้ Claude Code โต้ตอบกับเซิร์ฟเวอร์สำหรับพัฒนาของคุณโดยตรง ทุกอย่างเขียนสคริปต์ได้ผ่าน CLI และ socket API — สร้างเวิร์กสเปซ/แท็บ แบ่งแผง ส่งการกดแป้นพิมพ์ เปิด URL ในเบราว์เซอร์ +## The Zen of cmux + +cmux ไม่ได้กำหนดว่านักพัฒนาต้องใช้เครื่องมืออย่างไร มันเป็นเทอร์มินัลและเบราว์เซอร์พร้อม CLI ส่วนที่เหลือขึ้นอยู่กับคุณ + +cmux เป็นส่วนประกอบพื้นฐาน ไม่ใช่โซลูชันสำเร็จรูป มันให้เทอร์มินัล เบราว์เซอร์ การแจ้งเตือน เวิร์กสเปซ แผงแบ่ง แท็บ และ CLI เพื่อควบคุมทั้งหมด cmux ไม่บังคับให้คุณใช้เอเจนต์เขียนโค้ดในแบบที่มีความคิดเห็นตายตัว สิ่งที่คุณสร้างด้วยส่วนประกอบพื้นฐานเหล่านี้เป็นของคุณ + +นักพัฒนาที่ดีที่สุดสร้างเครื่องมือของตัวเองมาตลอด ยังไม่มีใครหาวิธีทำงานกับเอเจนต์ที่ดีที่สุด และทีมที่สร้างผลิตภัณฑ์แบบปิดก็ยังไม่ได้หาเช่นกัน นักพัฒนาที่อยู่ใกล้โค้ดเบสของตัวเองมากที่สุดจะเป็นคนหาคำตอบก่อน + +ให้ส่วนประกอบพื้นฐานที่ประกอบกันได้แก่นักพัฒนาล้านคน แล้วพวกเขาจะร่วมกันค้นพบเวิร์กโฟลว์ที่มีประสิทธิภาพที่สุดได้เร็วกว่าทีมผลิตภัณฑ์ใดจะออกแบบจากบนลงล่าง + +## เอกสารประกอบ + +สำหรับข้อมูลเพิ่มเติมเกี่ยวกับการตั้งค่า cmux, [ไปที่เอกสารของเรา](https://cmux.dev/docs/getting-started?utm_source=readme) + ## ปุ่มลัด ### เวิร์กสเปซ @@ -78,20 +135,21 @@ brew upgrade --cask cmux | ⌃ ⌘ ] | เวิร์กสเปซถัดไป | | ⌃ ⌘ [ | เวิร์กสเปซก่อนหน้า | | ⌘ ⇧ W | ปิดเวิร์กสเปซ | +| ⌘ ⇧ R | เปลี่ยนชื่อเวิร์กสเปซ | | ⌘ B | สลับแถบด้านข้าง | -### Surfaces +### เซอร์เฟซ | ปุ่มลัด | การทำงาน | |----------|--------| -| ⌘ T | Surface ใหม่ | -| ⌘ ⇧ ] | Surface ถัดไป | -| ⌘ ⇧ [ | Surface ก่อนหน้า | -| ⌃ Tab | Surface ถัดไป | -| ⌃ ⇧ Tab | Surface ก่อนหน้า | -| ⌃ 1–8 | ข้ามไป surface 1–8 | -| ⌃ 9 | ข้ามไป surface สุดท้าย | -| ⌘ W | ปิด surface | +| ⌘ T | เซอร์เฟซใหม่ | +| ⌘ ⇧ ] | เซอร์เฟซถัดไป | +| ⌘ ⇧ [ | เซอร์เฟซก่อนหน้า | +| ⌃ Tab | เซอร์เฟซถัดไป | +| ⌃ ⇧ Tab | เซอร์เฟซก่อนหน้า | +| ⌃ 1–8 | ข้ามไปเซอร์เฟซ 1–8 | +| ⌃ 9 | ข้ามไปเซอร์เฟซสุดท้าย | +| ⌘ W | ปิดเซอร์เฟซ | ### แผงแบ่ง @@ -104,6 +162,8 @@ brew upgrade --cask cmux ### เบราว์เซอร์ +ปุ่มลัดเครื่องมือสำหรับนักพัฒนาของเบราว์เซอร์ใช้ค่าเริ่มต้นของ Safari และสามารถปรับแต่งได้ใน `Settings → Keyboard Shortcuts` + | ปุ่มลัด | การทำงาน | |----------|--------| | ⌘ ⇧ L | เปิดเบราว์เซอร์ในแผงแบ่ง | @@ -111,7 +171,8 @@ brew upgrade --cask cmux | ⌘ [ | ย้อนกลับ | | ⌘ ] | ไปข้างหน้า | | ⌘ R | โหลดหน้าใหม่ | -| ⌥ ⌘ I | เปิดเครื่องมือสำหรับนักพัฒนา | +| ⌥ ⌘ I | เปิด/ปิดเครื่องมือสำหรับนักพัฒนา (ค่าเริ่มต้น Safari) | +| ⌥ ⌘ C | แสดง JavaScript Console (ค่าเริ่มต้น Safari) | ### การแจ้งเตือน @@ -148,6 +209,63 @@ brew upgrade --cask cmux | ⌘ ⇧ , | โหลดการตั้งค่าใหม่ | | ⌘ Q | ออก | +## บิลด์ Nightly + +[ดาวน์โหลด cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY เป็นแอปแยกต่างหากที่มี bundle ID เป็นของตัวเอง จึงสามารถรันควบคู่กับเวอร์ชันเสถียรได้ สร้างอัตโนมัติจากคอมมิต `main` ล่าสุดและอัปเดตอัตโนมัติผ่านฟีด Sparkle ของตัวเอง + +## การกู้คืนเซสชัน (พฤติกรรมปัจจุบัน) + +เมื่อเปิดใหม่ cmux จะกู้คืนเลย์เอาต์และข้อมูลเมตาของแอปเท่านั้น: +- เลย์เอาต์หน้าต่าง/เวิร์กสเปซ/แผง +- ไดเรกทอรีทำงาน +- ประวัติการเลื่อนของเทอร์มินัล (พยายามอย่างดีที่สุด) +- URL ของเบราว์เซอร์และประวัติการนำทาง + +cmux **ไม่**กู้คืนสถานะกระบวนการที่กำลังทำงานภายในแอปเทอร์มินัล ตัวอย่างเช่น เซสชัน Claude Code/tmux/vim ที่กำลังทำงานอยู่จะยังไม่ถูกกู้คืนหลังจากรีสตาร์ท + +## ประวัติดาว + +<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> + +## การมีส่วนร่วม + +วิธีเข้าร่วม: + +- ติดตามเราบน X สำหรับข่าวสาร [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) และ [@austinywang](https://x.com/austinywang) +- เข้าร่วมสนทนาบน [Discord](https://discord.gg/xsgFEVrWCZ) +- สร้างและมีส่วนร่วมใน [GitHub issues](https://github.com/manaflow-ai/cmux/issues) และ [discussions](https://github.com/manaflow-ai/cmux/discussions) +- แจ้งให้เรารู้ว่าคุณกำลังสร้างอะไรด้วย cmux + +## ชุมชน + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Founder's Edition + +cmux เป็นซอฟต์แวร์ฟรี โอเพนซอร์ส และจะเป็นเช่นนั้นตลอดไป หากคุณต้องการสนับสนุนการพัฒนาและเข้าถึงสิ่งที่กำลังจะมาถึงก่อนใคร: + +**[รับ Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **คำขอฟีเจอร์/แก้ไขบั๊กที่ได้รับความสำคัญ** +- **เข้าถึงก่อน: cmux AI ที่ให้บริบทเกี่ยวกับทุกเวิร์กสเปซ แท็บ และแผง** +- **เข้าถึงก่อน: แอป iOS ที่ซิงค์เทอร์มินัลระหว่างเดสก์ท็อปและโทรศัพท์** +- **เข้าถึงก่อน: Cloud VMs** +- **เข้าถึงก่อน: โหมดเสียง** +- **iMessage/WhatsApp ส่วนตัวของผม** + ## สัญญาอนุญาต โปรเจกต์นี้อยู่ภายใต้สัญญาอนุญาต GNU Affero General Public License v3.0 หรือใหม่กว่า (`AGPL-3.0-or-later`) diff --git a/README.tr.md b/README.tr.md index 0c4278b8..a69c4a29 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,9 +1,5 @@ > Bu çeviri Claude tarafından oluşturulmuştur. İyileştirme önerileriniz varsa lütfen bir PR açın. -<p align="center"> - <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | Türkçe -</p> - <h1 align="center">cmux</h1> <p align="center">AI kodlama ajanları için dikey sekmeler ve bildirimler içeren Ghostty tabanlı macOS terminali</p> @@ -14,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="cmux ekran görüntüsü" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | Türkçe | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmux ekran görüntüsü" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo videosu</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## Özellikler -- **Dikey sekmeler** — Kenar çubuğu git dalını, çalışma dizinini, dinlenen portları ve en son bildirim metnini gösterir -- **Bildirim halkaları** — AI ajanları (Claude Code, OpenCode) dikkatinizi istediğinde paneller mavi bir halka alır ve sekmeler yanar -- **Bildirim paneli** — Bekleyen tüm bildirimleri tek bir yerden görün, en son okunmamışa atlayın -- **Bölünmüş paneller** — Yatay ve dikey bölmeler -- **Uygulama içi tarayıcı** — [agent-browser](https://github.com/vercel-labs/agent-browser)'dan aktarılmış betiklenebilir bir API ile terminalinizin yanında bir tarayıcı bölün +<table> +<tr> +<td width="40%" valign="middle"> +<h3>Bildirim halkaları</h3> +Kodlama ajanları dikkatinizi istediğinde paneller mavi bir halka alır ve sekmeler yanar +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="Bildirim halkaları" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Bildirim paneli</h3> +Bekleyen tüm bildirimleri tek bir yerden görün, en son okunmamışa atlayın +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="Kenar çubuğu bildirim rozeti" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Uygulama içi tarayıcı</h3> +<a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>'dan aktarılmış betiklenebilir bir API ile terminalinizin yanında bir tarayıcı bölün +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="Yerleşik tarayıcı" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>Dikey + yatay sekmeler</h3> +Kenar çubuğu git dalını, bağlantılı PR durumunu/numarasını, çalışma dizinini, dinlenen portları ve en son bildirim metnini gösterir. Yatay ve dikey bölmeler. +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Dikey sekmeler ve bölünmüş paneller" width="100%" /> +</td> +</tr> +</table> + - **Betiklenebilir** — Çalışma alanları oluşturmak, panelleri bölmek, tuş vuruşları göndermek ve tarayıcıyı otomatikleştirmek için CLI ve socket API - **Yerel macOS uygulaması** — Swift ve AppKit ile yapılmıştır, Electron değil. Hızlı başlangıç, düşük bellek kullanımı. - **Ghostty uyumlu** — Temalar, yazı tipleri ve renkler için mevcut `~/.config/ghostty/config` dosyanızı okur @@ -60,12 +103,26 @@ Birçok Claude Code ve Codex oturumunu paralel olarak çalıştırıyorum. Ghost Birkaç kodlama orkestratörü denedim ama çoğu Electron/Tauri uygulamasıydı ve performansları beni rahatsız ediyordu. Ayrıca terminali tercih ediyorum çünkü GUI orkestratörleri sizi kendi iş akışlarına kilitliyor. Bu yüzden cmux'u Swift/AppKit'te yerel bir macOS uygulaması olarak geliştirdim. Terminal görüntüleme için libghostty kullanıyor ve temalar, yazı tipleri ve renkler için mevcut Ghostty yapılandırmanızı okuyor. -Ana eklemeler kenar çubuğu ve bildirim sistemi. Kenar çubuğunda her çalışma alanı için git dalını, çalışma dizinini, dinlenen portları ve en son bildirim metnini gösteren dikey sekmeler var. Bildirim sistemi terminal dizilerini (OSC 9/99/777) yakalıyor ve Claude Code, OpenCode vb. için ajan kancalarına bağlayabileceğiniz bir CLI'ye (`cmux notify`) sahip. Bir ajan beklerken paneli mavi bir halka alıyor ve sekme kenar çubuğunda yanıyor, böylece bölmeler ve sekmeler arasında hangisinin bana ihtiyacı olduğunu görebiliyorum. Cmd+Shift+U en son okunmamışa atlıyor. +Ana eklemeler kenar çubuğu ve bildirim sistemi. Kenar çubuğunda her çalışma alanı için git dalını, bağlantılı PR durumunu/numarasını, çalışma dizinini, dinlenen portları ve en son bildirim metnini gösteren dikey sekmeler var. Bildirim sistemi terminal dizilerini (OSC 9/99/777) yakalıyor ve Claude Code, OpenCode vb. için ajan kancalarına bağlayabileceğiniz bir CLI'ye (`cmux notify`) sahip. Bir ajan beklerken paneli mavi bir halka alıyor ve sekme kenar çubuğunda yanıyor, böylece bölmeler ve sekmeler arasında hangisinin bana ihtiyacı olduğunu görebiliyorum. Cmd+Shift+U en son okunmamışa atlıyor. Uygulama içi tarayıcının [agent-browser](https://github.com/vercel-labs/agent-browser)'dan aktarılmış betiklenebilir bir API'si var. Ajanlar erişilebilirlik ağacının anlık görüntüsünü alabilir, öğe referansları elde edebilir, tıklayabilir, formları doldurabilir ve JS çalıştırabilir. Terminalinizin yanında bir tarayıcı paneli bölebilir ve Claude Code'un geliştirme sunucunuzla doğrudan etkileşime girmesini sağlayabilirsiniz. Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanları/sekmeler oluşturun, panelleri bölün, tuş vuruşları gönderin, tarayıcıda URL'ler açın. +## The Zen of cmux + +cmux, geliştiricilerin araçlarını nasıl kullandığını dikte etmez. Bir terminal ve tarayıcı ile CLI'dir, geri kalanı size kalmış. + +cmux bir ilkel yapıdır, hazır bir çözüm değil. Size bir terminal, bir tarayıcı, bildirimler, çalışma alanları, bölmeler, sekmeler ve hepsini kontrol etmek için bir CLI verir. cmux sizi kodlama ajanlarını belirli bir şekilde kullanmaya zorlamaz. İlkel yapılarla ne inşa edeceğiniz tamamen size aittir. + +En iyi geliştiriciler her zaman kendi araçlarını yapmıştır. Ajanlarla çalışmanın en iyi yolunu henüz kimse bulamadı ve kapalı ürünler geliştiren ekipler de kesinlikle bulamadı. Kendi kod tabanlarına en yakın olan geliştiriciler bunu ilk keşfedenler olacak. + +Bir milyon geliştiriciye birleştirilebilir ilkel yapılar verin, en verimli iş akışlarını herhangi bir ürün ekibinin yukarıdan aşağıya tasarlayabileceğinden daha hızlı bulacaklardır. + +## Dokümantasyon + +cmux'u nasıl yapılandıracağınız hakkında daha fazla bilgi için, [dokümantasyonumuza gidin](https://cmux.dev/docs/getting-started?utm_source=readme). + ## Klavye Kısayolları ### Çalışma Alanları @@ -78,6 +135,7 @@ Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanla | ⌃ ⌘ ] | Sonraki çalışma alanı | | ⌃ ⌘ [ | Önceki çalışma alanı | | ⌘ ⇧ W | Çalışma alanını kapat | +| ⌘ ⇧ R | Çalışma alanını yeniden adlandır | | ⌘ B | Kenar çubuğunu aç/kapat | ### Surfaces @@ -104,6 +162,8 @@ Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanla ### Tarayıcı +Tarayıcı geliştirici araçları kısayolları Safari varsayılanlarını takip eder ve `Settings → Keyboard Shortcuts` bölümünden özelleştirilebilir. + | Kısayol | Eylem | |----------|--------| | ⌘ ⇧ L | Bölmede tarayıcı aç | @@ -111,7 +171,8 @@ Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanla | ⌘ [ | Geri | | ⌘ ] | İleri | | ⌘ R | Sayfayı yeniden yükle | -| ⌥ ⌘ I | Geliştirici Araçlarını aç | +| ⌥ ⌘ I | Geliştirici Araçlarını aç/kapat (Safari varsayılanı) | +| ⌥ ⌘ C | JavaScript Konsolunu göster (Safari varsayılanı) | ### Bildirimler @@ -148,6 +209,63 @@ Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanla | ⌘ ⇧ , | Yapılandırmayı yeniden yükle | | ⌘ Q | Çıkış | +## Nightly Sürümler + +[cmux NIGHTLY'i indir](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY, kendi bundle ID'sine sahip ayrı bir uygulamadır, bu yüzden kararlı sürümle yan yana çalışır. En son `main` commit'inden otomatik olarak derlenir ve kendi Sparkle akışı aracılığıyla otomatik güncellenir. + +## Oturum geri yükleme (mevcut davranış) + +Yeniden başlatıldığında, cmux şu anda yalnızca uygulama düzenini ve meta verileri geri yükler: +- Pencere/çalışma alanı/panel düzeni +- Çalışma dizinleri +- Terminal kaydırma geçmişi (en iyi çaba) +- Tarayıcı URL'si ve gezinme geçmişi + +cmux, terminal uygulamaları içindeki canlı işlem durumunu geri **yüklemez**. Örneğin, aktif Claude Code/tmux/vim oturumları yeniden başlatma sonrasında henüz devam ettirilmez. + +## Yıldız Geçmişi + +<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> + +## Katkıda Bulunma + +Katılım yolları: + +- Güncellemeler için bizi X'te takip edin [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) ve [@austinywang](https://x.com/austinywang) +- [Discord](https://discord.gg/xsgFEVrWCZ)'da sohbete katılın +- [GitHub issues](https://github.com/manaflow-ai/cmux/issues) ve [discussions](https://github.com/manaflow-ai/cmux/discussions) oluşturun ve katılın +- cmux ile ne inşa ettiğinizi bize bildirin + +## Topluluk + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Founder's Edition + +cmux ücretsiz, açık kaynak ve her zaman öyle olacak. Geliştirmeyi desteklemek ve sırada ne olduğuna erken erişim almak isterseniz: + +**[Founder's Edition'ı Edinin](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **Öncelikli özellik istekleri/hata düzeltmeleri** +- **Erken erişim: Her çalışma alanı, sekme ve panel hakkında bağlam sağlayan cmux AI** +- **Erken erişim: Masaüstü ve telefon arasında senkronize terminallere sahip iOS uygulaması** +- **Erken erişim: Bulut VM'ler** +- **Erken erişim: Sesli mod** +- **Kişisel iMessage/WhatsApp'ım** + ## Lisans Bu proje GNU Affero Genel Kamu Lisansı v3.0 veya sonrası (`AGPL-3.0-or-later`) ile lisanslanmıştır. diff --git a/README.zh-CN.md b/README.zh-CN.md index 703c453b..f376b5f0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,7 +1,5 @@ > 此翻译由 Claude 生成。如有改进建议,欢迎提交 PR。 -<p align="center"><a href="README.md">English</a> | 简体中文 | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a></p> - <h1 align="center">cmux</h1> <p align="center">基于 Ghostty 的 macOS 终端,带有垂直标签页和为 AI 编程代理设计的通知系统</p> @@ -12,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="cmux 截图" width="900" /> + <a href="README.md">English</a> | 简体中文 | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmux 截图" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ 演示视频</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## 功能特性 -- **垂直标签页** — 侧边栏显示 git 分支、工作目录、监听端口和最新通知文本 -- **通知提示环** — 当 AI 代理(Claude Code、OpenCode)需要您注意时,窗格会显示蓝色光环,标签页会高亮 -- **通知面板** — 在一处查看所有待处理通知,快速跳转到最新未读通知 -- **分割窗格** — 支持水平和垂直分割 -- **内置浏览器** — 在终端旁边分割出浏览器窗格,提供从 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可脚本化 API +<table> +<tr> +<td width="40%" valign="middle"> +<h3>通知提示环</h3> +当编程代理需要您注意时,窗格会显示蓝色光环,标签页会高亮 +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="通知提示环" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>通知面板</h3> +在一处查看所有待处理通知,快速跳转到最新未读通知 +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="侧边栏通知徽章" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>内置浏览器</h3> +在终端旁边分割出浏览器窗格,提供从 <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> 移植的可脚本化 API +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="内置浏览器" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>垂直 + 水平标签页</h3> +侧边栏显示 git 分支、关联 PR 状态/编号、工作目录、监听端口和最新通知文本。支持水平和垂直分割。 +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="垂直标签页和分割窗格" width="100%" /> +</td> +</tr> +</table> + - **可脚本化** — 通过 CLI 和 socket API 创建工作区、分割窗格、发送按键和自动化浏览器操作 - **原生 macOS 应用** — 使用 Swift 和 AppKit 构建,非 Electron。启动快速,内存占用低。 - **兼容 Ghostty** — 读取您现有的 `~/.config/ghostty/config` 配置文件中的主题、字体和颜色设置 @@ -58,12 +103,26 @@ brew upgrade --cask cmux 我试过几个编程协调工具,但大多数都是 Electron/Tauri 应用,性能让我不满意。我也更喜欢终端,因为 GUI 协调工具会把你锁定在它们的工作流里。所以我用 Swift/AppKit 构建了 cmux,作为一个原生 macOS 应用。它使用 libghostty 进行终端渲染,并读取您现有的 Ghostty 配置中的主题、字体和颜色设置。 -主要新增的是侧边栏和通知系统。侧边栏有垂直标签页,显示每个工作区的 git 分支、工作目录、监听端口和最新通知文本。通知系统能捕获终端序列(OSC 9/99/777),并提供 CLI(`cmux notify`),您可以将其接入 Claude Code、OpenCode 等代理的钩子。当代理等待时,其窗格会显示蓝色光环,标签页会在侧边栏高亮,这样我就能在多个分割窗格和标签页之间一眼看出哪个需要我。⌘⇧U 可以跳转到最新的未读通知。 +主要新增的是侧边栏和通知系统。侧边栏有垂直标签页,显示每个工作区的 git 分支、关联 PR 状态/编号、工作目录、监听端口和最新通知文本。通知系统能捕获终端序列(OSC 9/99/777),并提供 CLI(`cmux notify`),您可以将其接入 Claude Code、OpenCode 等代理的钩子。当代理等待时,其窗格会显示蓝色光环,标签页会在侧边栏高亮,这样我就能在多个分割窗格和标签页之间一眼看出哪个需要我。⌘⇧U 可以跳转到最新的未读通知。 内置浏览器拥有从 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可脚本化 API。代理可以抓取无障碍树快照、获取元素引用、执行点击、填写表单和执行 JS。您可以在终端旁边分割出浏览器窗格,让 Claude Code 直接与您的开发服务器交互。 所有操作都可以通过 CLI 和 socket API 进行脚本化 — 创建工作区/标签页、分割窗格、发送按键、在浏览器中打开 URL。 +## The Zen of cmux + +cmux 不规定开发者应该如何使用工具。它是一个带有 CLI 的终端和浏览器,其余的由你决定。 + +cmux 是原语,而非解决方案。它提供终端、浏览器、通知、工作区、分割、标签页,以及控制这一切的 CLI。cmux 不强迫你以特定方式使用编程代理。你用这些原语构建什么,完全取决于你自己。 + +最优秀的开发者一直在构建自己的工具。还没有人找到与代理协作的最佳方式,那些构建封闭产品的团队也没有找到。最接近自己代码库的开发者会最先找到答案。 + +给一百万个开发者可组合的原语,他们会比任何自上而下设计的产品团队更快地找到最高效的工作流。 + +## 文档 + +有关 cmux 配置的更多信息,请[查看我们的文档](https://cmux.dev/docs/getting-started?utm_source=readme)。 + ## 键盘快捷键 ### 工作区 @@ -76,6 +135,7 @@ brew upgrade --cask cmux | ⌃ ⌘ ] | 下一个工作区 | | ⌃ ⌘ [ | 上一个工作区 | | ⌘ ⇧ W | 关闭工作区 | +| ⌘ ⇧ R | 重命名工作区 | | ⌘ B | 切换侧边栏 | ### 界面 @@ -102,6 +162,8 @@ brew upgrade --cask cmux ### 浏览器 +浏览器开发者工具快捷键遵循 Safari 默认设置,可在`设置 → 键盘快捷键`中自定义。 + | 快捷键 | 操作 | |----------|--------| | ⌘ ⇧ L | 在分割中打开浏览器 | @@ -109,7 +171,8 @@ brew upgrade --cask cmux | ⌘ [ | 后退 | | ⌘ ] | 前进 | | ⌘ R | 刷新页面 | -| ⌥ ⌘ I | 打开开发者工具 | +| ⌥ ⌘ I | 切换开发者工具(Safari 默认) | +| ⌥ ⌘ C | 显示 JavaScript 控制台(Safari 默认) | ### 通知 @@ -146,6 +209,63 @@ brew upgrade --cask cmux | ⌘ ⇧ , | 重新加载配置 | | ⌘ Q | 退出 | +## 每夜构建 + +[下载 cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY 是一个拥有独立 Bundle ID 的单独应用,因此可以与稳定版并行运行。它从最新的 `main` 提交自动构建,并通过独立的 Sparkle 更新源自动更新。 + +## 会话恢复(当前行为) + +重新启动时,cmux 目前仅恢复应用布局和元数据: +- 窗口/工作区/窗格布局 +- 工作目录 +- 终端回滚缓冲区(尽力恢复) +- 浏览器 URL 和导航历史 + +cmux **不会**恢复终端应用内部的实时进程状态。例如,活动的 Claude Code/tmux/vim 会话在重启后尚无法恢复。 + +## 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> + +## 参与贡献 + +参与方式: + +- 在 X 上关注我们:[@manaflowai](https://x.com/manaflowai)、[@lawrencecchen](https://x.com/lawrencecchen)、[@austinywang](https://x.com/austinywang) +- 加入 [Discord](https://discord.gg/xsgFEVrWCZ) 讨论 +- 创建和参与 [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) 和[讨论](https://github.com/manaflow-ai/cmux/discussions) +- 告诉我们您在用 cmux 构建什么 + +## 社区 + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## Founder's Edition + +cmux 免费、开源,并将一直如此。如果您想支持开发并提前体验即将推出的功能: + +**[获取 Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **功能请求/Bug 修复优先处理** +- **抢先体验:为每个工作区、标签页和面板提供上下文的 cmux AI** +- **抢先体验:桌面与手机间终端同步的 iOS 应用** +- **抢先体验:云端虚拟机** +- **抢先体验:语音模式** +- **我的个人 iMessage/WhatsApp** + ## 许可证 本项目采用 GNU Affero 通用公共许可证 v3.0 或更高版本(`AGPL-3.0-or-later`)授权。 diff --git a/README.zh-TW.md b/README.zh-TW.md index 6cb4bf37..fee17fd4 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,7 +1,5 @@ > 此翻譯由 Claude 生成。如有改進建議,歡迎提交 PR。 -<p align="center"><a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 繁體中文 | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a></p> - <h1 align="center">cmux</h1> <p align="center">基於 Ghostty 的 macOS 終端機,具備垂直分頁和為 AI 程式設計代理設計的通知系統</p> @@ -12,16 +10,63 @@ </p> <p align="center"> - <img src="./docs/assets/screenshot.png" alt="cmux 螢幕截圖" width="900" /> + <a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 繁體中文 | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a> +</p> + +<p align="center"> + <a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a> + <a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a> +</p> + +<p align="center"> + <img src="./docs/assets/main-first-image.png" alt="cmux 螢幕截圖" width="900" /> +</p> + +<p align="center"> + <a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ 示範影片</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a> </p> ## 功能特色 -- **垂直分頁** — 側邊欄顯示 git 分支、工作目錄、監聽連接埠和最新通知文字 -- **通知提示環** — 當 AI 代理(Claude Code、OpenCode)需要您注意時,窗格會顯示藍色光環,分頁會亮起 -- **通知面板** — 在同一處檢視所有待處理通知,快速跳轉到最新未讀通知 -- **分割窗格** — 支援水平和垂直分割 -- **內建瀏覽器** — 在終端機旁分割出瀏覽器窗格,提供從 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可腳本化 API +<table> +<tr> +<td width="40%" valign="middle"> +<h3>通知提示環</h3> +當 AI 代理需要您注意時,窗格會顯示藍色光環,分頁會亮起 +</td> +<td width="60%"> +<img src="./docs/assets/notification-rings.png" alt="通知提示環" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>通知面板</h3> +在同一處檢視所有待處理通知,快速跳轉到最新未讀通知 +</td> +<td width="60%"> +<img src="./docs/assets/sidebar-notification-badge.png" alt="側邊欄通知徽章" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>內建瀏覽器</h3> +在終端機旁分割出瀏覽器窗格,提供從 <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> 移植的可腳本化 API +</td> +<td width="60%"> +<img src="./docs/assets/built-in-browser.png" alt="內建瀏覽器" width="100%" /> +</td> +</tr> +<tr> +<td width="40%" valign="middle"> +<h3>垂直 + 水平分頁</h3> +側邊欄顯示 git 分支、關聯的 PR 狀態/編號、工作目錄、監聽連接埠和最新通知文字。支援水平和垂直分割。 +</td> +<td width="60%"> +<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="垂直分頁和分割窗格" width="100%" /> +</td> +</tr> +</table> + - **可腳本化** — 透過 CLI 和 socket API 建立工作區、分割窗格、傳送按鍵和自動化瀏覽器操作 - **原生 macOS 應用程式** — 使用 Swift 和 AppKit 建構,非 Electron。啟動快速,記憶體佔用低。 - **相容 Ghostty** — 讀取您現有的 `~/.config/ghostty/config` 設定檔中的主題、字型和色彩設定 @@ -58,12 +103,26 @@ brew upgrade --cask cmux 我試過幾個程式設計協調工具,但大多數都是 Electron/Tauri 應用程式,效能讓我不滿意。我也更偏好終端機,因為 GUI 協調工具會把你鎖定在它們的工作流程裡。所以我用 Swift/AppKit 建構了 cmux,作為一個原生 macOS 應用程式。它使用 libghostty 進行終端機渲染,並讀取您現有的 Ghostty 設定中的主題、字型和色彩設定。 -主要新增的是側邊欄和通知系統。側邊欄有垂直分頁,顯示每個工作區的 git 分支、工作目錄、監聽連接埠和最新通知文字。通知系統能擷取終端機序列(OSC 9/99/777),並提供 CLI(`cmux notify`),您可以將其接入 Claude Code、OpenCode 等代理的鉤子。當代理等待時,其窗格會顯示藍色光環,分頁會在側邊欄亮起,這樣我就能在多個分割窗格和分頁之間一眼看出哪個需要我。⌘⇧U 可以跳轉到最新的未讀通知。 +主要新增的是側邊欄和通知系統。側邊欄有垂直分頁,顯示每個工作區的 git 分支、關聯的 PR 狀態/編號、工作目錄、監聽連接埠和最新通知文字。通知系統能擷取終端機序列(OSC 9/99/777),並提供 CLI(`cmux notify`),您可以將其接入 Claude Code、OpenCode 等代理的鉤子。當代理等待時,其窗格會顯示藍色光環,分頁會在側邊欄亮起,這樣我就能在多個分割窗格和分頁之間一眼看出哪個需要我。⌘⇧U 可以跳轉到最新的未讀通知。 內建瀏覽器擁有從 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可腳本化 API。代理可以擷取無障礙樹快照、取得元素參考、執行點擊、填寫表單和執行 JS。您可以在終端機旁分割出瀏覽器窗格,讓 Claude Code 直接與您的開發伺服器互動。 所有操作都可以透過 CLI 和 socket API 進行腳本化 — 建立工作區/分頁、分割窗格、傳送按鍵、在瀏覽器中開啟 URL。 +## The Zen of cmux + +cmux 不會規定開發者如何使用工具。它是一個帶有 CLI 的終端機和瀏覽器,其餘由您決定。 + +cmux 是一個基礎元件,而非完整方案。它提供終端機、瀏覽器、通知、工作區、分割、分頁,以及控制一切的 CLI。cmux 不會強迫您採用特定的方式使用程式設計代理。您用這些基礎元件打造什麼,由您決定。 + +最好的開發者一直在打造自己的工具。沒有人知道與代理協作的最佳方式,那些打造封閉產品的團隊也一樣。最了解自己程式碼庫的開發者會最先找到答案。 + +給一百萬個開發者可組合的基礎元件,他們會比任何自上而下設計的產品團隊更快地集體找到最高效的工作流程。 + +## 文件 + +如需更多 cmux 設定資訊,[請前往我們的文件](https://cmux.dev/docs/getting-started?utm_source=readme)。 + ## 鍵盤快捷鍵 ### 工作區 @@ -76,6 +135,7 @@ brew upgrade --cask cmux | ⌃ ⌘ ] | 下一個工作區 | | ⌃ ⌘ [ | 上一個工作區 | | ⌘ ⇧ W | 關閉工作區 | +| ⌘ ⇧ R | 重新命名工作區 | | ⌘ B | 切換側邊欄 | ### 介面 @@ -102,6 +162,8 @@ brew upgrade --cask cmux ### 瀏覽器 +瀏覽器開發者工具快捷鍵遵循 Safari 預設設定,可在 `設定 → 鍵盤快捷鍵` 中自訂。 + | 快捷鍵 | 動作 | |----------|--------| | ⌘ ⇧ L | 在分割中開啟瀏覽器 | @@ -109,7 +171,8 @@ brew upgrade --cask cmux | ⌘ [ | 後退 | | ⌘ ] | 前進 | | ⌘ R | 重新整理頁面 | -| ⌥ ⌘ I | 開啟開發者工具 | +| ⌥ ⌘ I | 切換開發者工具(Safari 預設) | +| ⌥ ⌘ C | 顯示 JavaScript 主控台(Safari 預設) | ### 通知 @@ -146,6 +209,63 @@ brew upgrade --cask cmux | ⌘ ⇧ , | 重新載入設定 | | ⌘ Q | 結束 | +## 每夜建構 + +[下載 cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) + +cmux NIGHTLY 是一個獨立的應用程式,擁有自己的 bundle ID,因此可以與穩定版並行執行。每次從最新的 `main` 提交自動建構,並透過自己的 Sparkle 來源自動更新。 + +## 工作階段還原(目前行為) + +重新啟動時,cmux 目前僅還原應用程式佈局和中繼資料: +- 視窗/工作區/窗格佈局 +- 工作目錄 +- 終端機捲動緩衝區(盡力而為) +- 瀏覽器 URL 和瀏覽歷程 + +cmux **不會**還原終端機應用程式內的即時程序狀態。例如,活躍的 Claude Code/tmux/vim 工作階段在重新啟動後尚無法恢復。 + +## Star 歷史 + +<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> + +## 參與貢獻 + +參與方式: + +- 在 X 上追蹤我們獲取最新動態 [@manaflowai](https://x.com/manaflowai)、[@lawrencecchen](https://x.com/lawrencecchen) 和 [@austinywang](https://x.com/austinywang) +- 加入 [Discord](https://discord.gg/xsgFEVrWCZ) 上的討論 +- 建立和參與 [GitHub issues](https://github.com/manaflow-ai/cmux/issues) 和 [discussions](https://github.com/manaflow-ai/cmux/discussions) +- 讓我們知道您正在用 cmux 打造什麼 + +## 社群 + +- [Discord](https://discord.gg/xsgFEVrWCZ) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) +- [Reddit](https://www.reddit.com/r/cmux/) + +## 創始版 + +cmux 免費、開源,且將永遠如此。如果您想支持開發並提前體驗即將推出的功能: + +**[取得創始版](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)** + +- **優先處理的功能請求/錯誤修復** +- **搶先體驗:cmux AI 為您提供每個工作區、分頁和面板的上下文資訊** +- **搶先體驗:iOS 應用程式,終端機在桌面和手機之間同步** +- **搶先體驗:雲端虛擬機** +- **搶先體驗:語音模式** +- **我的個人 iMessage/WhatsApp** + ## 授權條款 本專案採用 GNU Affero 通用公共授權條款 v3.0 或更新版本(`AGPL-3.0-or-later`)授權。 diff --git a/Resources/Info.plist b/Resources/Info.plist index 8e323ec1..48d4f800 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -26,8 +26,32 @@ <string></string> <key>NSMainStoryboardFile</key> <string></string> + <key>NSMicrophoneUsageDescription</key> + <string>A program running within cmux would like to use your microphone.</string> + <key>NSCameraUsageDescription</key> + <string>A program running within cmux would like to use your camera.</string> + <key>CFBundleURLTypes</key> + <array> + <dict> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>CFBundleURLName</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER).web</string> + <key>LSHandlerRank</key> + <string>Default</string> + <key>CFBundleURLSchemes</key> + <array> + <string>http</string> + <string>https</string> + </array> + </dict> + </array> <key>NSPrincipalClass</key> <string>NSApplication</string> + <key>NSAppleScriptEnabled</key> + <true/> + <key>OSAScriptingDefinition</key> + <string>cmux.sdef</string> <key>NSServices</key> <array> <dict> @@ -69,27 +93,15 @@ </array> </dict> </array> - <key>UTExportedTypeDeclarations</key> + <key>UTImportedTypeDeclarations</key> <array> <dict> <key>UTTypeIdentifier</key> <string>com.splittabbar.tabtransfer</string> - <key>UTTypeDescription</key> - <string>Bonsplit Tab Transfer</string> - <key>UTTypeConformsTo</key> - <array> - <string>public.data</string> - </array> </dict> <dict> <key>UTTypeIdentifier</key> <string>com.cmux.sidebar-tab-reorder</string> - <key>UTTypeDescription</key> - <string>cmux Sidebar Tab Reorder</string> - <key>UTTypeConformsTo</key> - <array> - <string>public.data</string> - </array> </dict> </array> <key>NSAppTransportSecurity</key> diff --git a/Resources/InfoPlist.xcstrings b/Resources/InfoPlist.xcstrings new file mode 100644 index 00000000..baeee708 --- /dev/null +++ b/Resources/InfoPlist.xcstrings @@ -0,0 +1,362 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "NSCameraUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A program running within cmux would like to use your camera." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux 内で実行中のプログラムがカメラの使用を求めています。" + } + } + } + }, + "NSMicrophoneUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A program running within cmux would like to use your microphone." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux 内で実行中のプログラムがマイクの使用を求めています。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 中运行的程序想要使用您的麦克风。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 中執行的程式想要使用您的麥克風。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 내에서 실행 중인 프로그램이 마이크를 사용하려고 합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein in cmux ausgeführtes Programm möchte Ihr Mikrofon verwenden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un programa en ejecución dentro de cmux desea usar tu micrófono." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un programme s'exécutant dans cmux souhaite utiliser votre microphone." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un programma in esecuzione in cmux desidera utilizzare il microfono." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Et program, der kører i cmux, vil gerne bruge din mikrofon." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Program działający w cmux chciałby użyć Twojego mikrofonu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Программа, запущенная в cmux, хотела бы использовать ваш микрофон." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Program koji se izvršava unutar cmux želi koristiti vaš mikrofon." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يرغب برنامج يعمل داخل cmux في استخدام الميكروفون." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Et program som kjører i cmux ønsker å bruke mikrofonen din." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Um programa em execução no cmux gostaria de usar seu microfone." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โปรแกรมที่ทำงานภายใน cmux ต้องการใช้ไมโครโฟนของคุณ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux içinde çalışan bir program mikrofonunuzu kullanmak istiyor." + } + } + } + }, + "New $(PRODUCT_NAME) Workspace Here": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New $(PRODUCT_NAME) Workspace Here" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここに新規 $(PRODUCT_NAME) ワークスペースを作成" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在此新建 $(PRODUCT_NAME) 工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在此新增 $(PRODUCT_NAME) 工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기에 새 $(PRODUCT_NAME) 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer $(PRODUCT_NAME)-Arbeitsbereich hier" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo espacio de trabajo de $(PRODUCT_NAME) aquí" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel espace de travail $(PRODUCT_NAME) ici" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova area di lavoro $(PRODUCT_NAME) qui" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt $(PRODUCT_NAME)-arbejdsområde her" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa przestrzeń robocza $(PRODUCT_NAME) tutaj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое рабочее пространство $(PRODUCT_NAME) здесь" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi $(PRODUCT_NAME) radni prostor ovdje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل $(PRODUCT_NAME) جديدة هنا" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt $(PRODUCT_NAME)-arbeidsområde her" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Área de Trabalho do $(PRODUCT_NAME) Aqui" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ $(PRODUCT_NAME) ใหม่ที่นี่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Buraya Yeni $(PRODUCT_NAME) Çalışma Alanı" + } + } + } + }, + "New $(PRODUCT_NAME) Window Here": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New $(PRODUCT_NAME) Window Here" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここに新規 $(PRODUCT_NAME) ウインドウを作成" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在此新建 $(PRODUCT_NAME) 窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在此新增 $(PRODUCT_NAME) 視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기에 새 $(PRODUCT_NAME) 윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neues $(PRODUCT_NAME)-Fenster hier" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva ventana de $(PRODUCT_NAME) aquí" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle fenêtre $(PRODUCT_NAME) ici" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova finestra $(PRODUCT_NAME) qui" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt $(PRODUCT_NAME)-vindue her" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowe okno $(PRODUCT_NAME) tutaj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое окно $(PRODUCT_NAME) здесь" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi $(PRODUCT_NAME) prozor ovdje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة $(PRODUCT_NAME) جديدة هنا" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt $(PRODUCT_NAME)-vindu her" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Janela do $(PRODUCT_NAME) Aqui" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่าง $(PRODUCT_NAME) ใหม่ที่นี่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Buraya Yeni $(PRODUCT_NAME) Penceresi" + } + } + } + } + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings new file mode 100644 index 00000000..63e1927a --- /dev/null +++ b/Resources/Localizable.xcstrings @@ -0,0 +1,73115 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "about.appName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + } + } + }, + "cli.claude-teams.usage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux claude-teams [claude-args...]\n\nLaunch Claude Code with agent teams enabled.\n\nThis command:\n - defaults Claude teammate mode to auto\n - sets a tmux-like environment so Claude auto mode uses cmux splits\n - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to claude\n\nThe tmux shim translates supported tmux window/pane commands into cmux\nworkspace and split operations in the current cmux session.\n\nExamples:\n cmux claude-teams\n cmux claude-teams --continue\n cmux claude-teams --model sonnet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux claude-teams [claude-args...]\n\nエージェントチームを有効にした状態で Claude Code を起動します。\n\nこのコマンドは次を行います:\n - Claude の teammate mode を auto に設定\n - Claude の auto mode が cmux の split を使うよう tmux 風の環境を設定\n - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 を設定\n - 専用の tmux shim を PATH の先頭に追加\n - 残りの引数をそのまま claude に渡す\n\ntmux shim は、対応している tmux の window/pane コマンドを、現在の cmux セッション内の workspace と split 操作に変換します。\n\n例:\n cmux claude-teams\n cmux claude-teams --continue\n cmux claude-teams --model sonnet" + } + } + } + }, + "applescript.error.disabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AppleScript is disabled by the macos-applescript configuration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "macos-applescript の設定で AppleScript は無効になっています。" + } + } + } + }, + "applescript.error.failedToCreateSplit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to create split." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割の作成に失敗しました。" + } + } + } + }, + "applescript.error.failedToCreateWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to create window." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウの作成に失敗しました。" + } + } + } + }, + "applescript.error.failedToCreateWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to create workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの作成に失敗しました。" + } + } + } + }, + "applescript.error.missingAction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing action string." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクション文字列がありません。" + } + } + } + }, + "applescript.error.missingInputText": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing input text." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力するテキストがありません。" + } + } + } + }, + "applescript.error.missingSplitDirection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or unknown split direction." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割方向がないか、不明です。" + } + } + } + }, + "applescript.error.missingTerminalTarget": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing terminal target." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象のターミナルがありません。" + } + } + } + }, + "applescript.error.terminalUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal is no longer available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルはもう利用できません。" + } + } + } + }, + "applescript.error.windowUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window is no longer available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウはもう利用できません。" + } + } + } + }, + "applescript.error.workspaceUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace is no longer available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースはもう利用できません。" + } + } + } + }, + "about.build": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Build" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Build" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "构建" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建置版本" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "빌드" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Build" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Compilación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Build" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Build" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Build" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kompilacja" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сборка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Verzija" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البناء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bygg" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Build" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บิลด์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Derleme" + } + } + } + }, + "about.commit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提交" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "提交" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "커밋" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Коммит" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإيداع" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คอมมิต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Commit" + } + } + } + }, + "about.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A Ghostty-based terminal with vertical tabs\\nand a notification panel for macOS." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Ghosttyベースの縦タブ付きターミナルと\nmacOS用通知パネル。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "基于 Ghostty 的 macOS 终端,\\n支持垂直标签页和通知面板。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "基於 Ghostty 的 macOS 終端機,\\n具備垂直標籤頁與通知面板。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세로 탭과 알림 패널을 갖춘\\nGhostty 기반 macOS 터미널." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Ghostty-basiertes Terminal mit vertikalen Tabs\\nund einem Benachrichtigungsfeld für macOS." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un terminal basado en Ghostty con pestañas verticales\\ny un panel de notificaciones para macOS." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un terminal basé sur Ghostty avec des onglets verticaux\\net un panneau de notifications pour macOS." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un terminale basato su Ghostty con schede verticali\\ne un pannello notifiche per macOS." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "En Ghostty-baseret terminal med lodrette faner\nog et notifikationspanel til macOS." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Terminal oparty na Ghostty z pionowymi kartami\ni panelem powiadomień dla macOS." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Терминал на базе Ghostty с вертикальными вкладками\\nи панелью уведомлений для macOS." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Terminal zasnovan na Ghostty sa vertikalnim tabovima\\ni panelom za obavještenja za macOS." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "طرفية مبنية على Ghostty مع ألسنة عمودية\\nولوحة إشعارات لنظام macOS." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "En Ghostty-basert terminal med vertikale faner\\nog et varselpanel for macOS." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Um terminal baseado no Ghostty com abas verticais\\ne um painel de notificações para macOS." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เทอร์มินัลบน Ghostty พร้อมแท็บแนวตั้ง\\nและแผงการแจ้งเตือนสำหรับ macOS" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "macOS için dikey sekmeli ve bildirim panelli\\nGhostty tabanlı terminal." + } + } + } + }, + "about.docs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Docs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドキュメント" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文档" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "文件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "문서" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dokumentation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Documentación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Documentation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Documentazione" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dokumentation" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dokumentacja" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Документация" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dokumentacija" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المستندات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dokumentasjon" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Documentação" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เอกสาร" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Belgeler" + } + } + } + }, + "debug.devBuildBanner.show": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Dev Build Banner" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開発ビルドバナーを表示" + } + } + } + }, + "debug.menu.openStressWorkspacesWithLoadedSurfaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Stress Workspaces and Load All Terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "負荷テスト用ワークスペースを開いてすべてのターミナルを読み込む" + } + } + } + }, + "debug.devBuildBanner.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "THIS IS A DEV BUILD" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これは開発ビルドです" + } + } + } + }, + "sidebar.help.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘルプ" + } + } + } + }, + "sidebar.help.welcome": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ようこそ" + } + } + } + }, + "sidebar.help.changelog": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Changelog" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新履歴" + } + } + } + }, + "sidebar.help.githubIssues": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub Issues" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GitHub Issues" + } + } + } + }, + "sidebar.help.sendFeedback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Feedback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信" + } + } + } + }, + "sidebar.help.feedback.attachImages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach Images" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像を添付" + } + } + } + }, + "sidebar.help.feedback.attachImages.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "添付" + } + } + } + }, + "sidebar.help.feedback.attachImages.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach Images" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像を添付" + } + } + } + }, + "sidebar.help.feedback.attachmentsHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Up to 10 images." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像は最大10枚まで添付できます。" + } + } + } + }, + "sidebar.help.feedback.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "sidebar.help.feedback.connectionError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't send feedback. Check your connection and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信できませんでした。接続を確認して、もう一度お試しください。" + } + } + } + }, + "sidebar.help.feedback.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + } + } + }, + "sidebar.help.feedback.email": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Email" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メールアドレス" + } + } + } + }, + "sidebar.help.feedback.emailPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "you@example.com" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "you@example.com" + } + } + } + }, + "sidebar.help.feedback.emptyMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a message before sending." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "送信する前にメッセージを入力してください。" + } + } + } + }, + "sidebar.help.feedback.endpointError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feedback is unavailable right now. Email founders@manaflow.com instead." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在フィードバックを送信できません。代わりに founders@manaflow.com までメールしてください。" + } + } + } + }, + "sidebar.help.feedback.genericError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't send feedback. Please try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信できませんでした。もう一度お試しください。" + } + } + } + }, + "sidebar.help.feedback.imageTooLarge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Each image must be 4 MB or smaller." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "各画像は 4 MB 以下にしてください。" + } + } + } + }, + "sidebar.help.feedback.invalidEmail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid email address." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効なメールアドレスを入力してください。" + } + } + } + }, + "sidebar.help.feedback.invalidImageSelection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One of the selected files could not be attached." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したファイルのうち1つを添付できませんでした。" + } + } + } + }, + "sidebar.help.feedback.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージ" + } + } + } + }, + "sidebar.help.feedback.messagePlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Share feedback, feature requests, or issues." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバック、機能要望、不具合をお知らせください。" + } + } + } + }, + "sidebar.help.feedback.messageTooLong": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your message is too long." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージが長すぎます。" + } + } + } + }, + "sidebar.help.feedback.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can also reach us at founders@manaflow.com." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "founders@manaflow.com 宛てに直接ご連絡いただくこともできます。" + } + } + } + }, + "sidebar.help.feedback.rateLimited": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many feedback attempts. Please try again later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックの送信回数が多すぎます。しばらくしてからもう一度お試しください。" + } + } + } + }, + "sidebar.help.feedback.removeAttachment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + } + } + }, + "sidebar.help.feedback.send": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "送信" + } + } + } + }, + "sidebar.help.feedback.successBody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can also reach us at founders@manaflow.com." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "founders@manaflow.com 宛てに直接ご連絡いただくこともできます。" + } + } + } + }, + "sidebar.help.feedback.successTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thanks for the feedback." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックありがとうございます。" + } + } + } + }, + "sidebar.help.feedback.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Feedback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信" + } + } + } + }, + "sidebar.help.feedback.tooManyImages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can attach up to 10 images." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像は最大10枚まで添付できます。" + } + } + } + }, + "sidebar.help.feedback.totalImagesTooLarge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These images are too large to send together. Remove a few and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これらの画像はまとめて送信するには大きすぎます。いくつか削除してもう一度お試しください。" + } + } + } + }, + "sidebar.help.feedback.validationError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check your message and attachments, then try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージと添付ファイルを確認して、もう一度お試しください。" + } + } + } + }, + "about.github": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + } + } + }, + "about.licenses": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Licenses" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライセンス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "许可证" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "授權條款" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이선스" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lizenzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Licencias" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Licences" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Licenze" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Licenser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Licencje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Лицензии" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Licence" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التراخيص" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lisenser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Licenças" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สัญญาอนุญาต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Lisanslar" + } + } + } + }, + "about.licenses.notFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Licenses file not found." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライセンスファイルが見つかりません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到许可证文件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到授權條款檔案。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이선스 파일을 찾을 수 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lizenzdatei nicht gefunden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró el archivo de licencias." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fichier de licences introuvable." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "File delle licenze non trovato." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Licensfilen blev ikke fundet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono pliku licencji." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Файл лицензий не найден." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Datoteka s licencama nije pronađena." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على ملف التراخيص." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lisensfilen ble ikke funnet." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Arquivo de licenças não encontrado." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบไฟล์สัญญาอนุญาต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Lisans dosyası bulunamadı." + } + } + } + }, + "about.licenses.windowTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Third-Party Licenses" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サードパーティライセンス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "第三方许可证" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "第三方授權條款" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서드파티 라이선스" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Drittanbieter-Lizenzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Licencias de terceros" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Licences tierces" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Licenze di terze parti" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tredjepartslicenser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Licencje stron trzecich" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Лицензии сторонних компонентов" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Licence trećih strana" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تراخيص الجهات الخارجية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tredjepartslisenser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Licenças de Terceiros" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สัญญาอนุญาตของบุคคลที่สาม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Üçüncü Taraf Lisansları" + } + } + } + }, + "about.version": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "版本" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버전" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Versión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Versione" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wersja" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Версия" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Verzija" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإصدار" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Versjon" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Versão" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวอร์ชัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sürüm" + } + } + } + }, + "accessibility.workspacePosition": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@, workspace %2$lld of %3$lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@、ワークスペース %3$lld中%2$lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@,工作区 %2$lld / %3$lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@,工作區 %2$lld / %3$lld" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@, 작업 공간 %2$lld / %3$lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@, Arbeitsbereich %2$lld von %3$lld" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@, espacio de trabajo %2$lld de %3$lld" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@, espace de travail %2$lld sur %3$lld" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%1$@, area di lavoro %2$lld di %3$lld" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%1$@, arbejdsområde %2$lld af %3$lld" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%1$@, przestrzeń robocza %2$lld z %3$lld" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%1$@, рабочее пространство %2$lld из %3$lld" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%1$@, radni prostor %2$lld od %3$lld" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@، مساحة العمل %2$lld من %3$lld" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%1$@, arbeidsområde %2$lld av %3$lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%1$@, área de trabalho %2$lld de %3$lld" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%1$@, เวิร์กสเปซที่ %2$lld จาก %3$lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%1$@, çalışma alanı %3$lld/%2$lld" + } + } + } + }, + "alert.customColor.apply": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apply" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "適用" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "套用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "적용" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anwenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appliquer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Applica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Anvend" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zastosuj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Применить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Primijeni" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تطبيق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aplicar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "นำไปใช้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uygula" + } + } + } + }, + "alert.customColor.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Annulla" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Annuller" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Anuluj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отменить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkaži" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avbryt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Vazgeç" + } + } + } + }, + "alert.customColor.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a hex color in the format #RRGGBB." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "#RRGGBB形式で16進カラーコードを入力してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请输入 #RRGGBB 格式的十六进制颜色。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請輸入 #RRGGBB 格式的十六進位色碼。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "#RRGGBB 형식으로 16진수 색상을 입력하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie eine Hex-Farbe im Format #RRGGBB ein." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce un color hexadecimal con el formato #RRGGBB." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez une couleur hexadécimale au format #RRVVBB." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci un colore esadecimale nel formato #RRGGBB." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indtast en hex-farve i formatet #RRGGBB." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wprowadź kolor szesnastkowy w formacie #RRGGBB." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите цвет в формате #RRGGBB." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Unesite heksadecimalnu boju u formatu #RRGGBB." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل لونًا سداسيًا بالتنسيق #RRGGBB." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skriv inn en heksadesimal farge i formatet #RRGGBB." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Insira uma cor hexadecimal no formato #RRGGBB." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป้อนรหัสสี hex ในรูปแบบ #RRGGBB" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "#RRGGBB biçiminde bir onaltılık renk girin." + } + } + } + }, + "alert.customColor.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Workspace Color" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムワークスペースカラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义工作区颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂工作區顏色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 작업 공간 색상" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Arbeitsbereichsfarbe" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color personalizado del espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur personnalisée de l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore personalizzato area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilpasset arbejdsområdefarve" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Własny kolor przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользовательский цвет рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prilagođena boja radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون مساحة عمل مخصص" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Egendefinert arbeidsområdefarge" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor Personalizada da Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีเวิร์กสเปซที่กำหนดเอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel Çalışma Alanı Rengi" + } + } + } + }, + "alert.invalidColor.emptyMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a hex color in the format #RRGGBB." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "#RRGGBB形式で16進カラーコードを入力してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请输入 #RRGGBB 格式的十六进制颜色。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請輸入 #RRGGBB 格式的十六進位色碼。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "#RRGGBB 형식으로 16진수 색상을 입력하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie eine Hex-Farbe im Format #RRGGBB ein." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce un color hexadecimal con el formato #RRGGBB." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez une couleur hexadécimale au format #RRVVBB." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci un colore esadecimale nel formato #RRGGBB." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indtast en hex-farve i formatet #RRGGBB." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wprowadź kolor szesnastkowy w formacie #RRGGBB." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите цвет в формате #RRGGBB." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Unesite heksadecimalnu boju u formatu #RRGGBB." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل لونًا سداسيًا بالتنسيق #RRGGBB." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skriv inn en heksadesimal farge i formatet #RRGGBB." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Insira uma cor hexadecimal no formato #RRGGBB." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป้อนรหัสสี hex ในรูปแบบ #RRGGBB" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "#RRGGBB biçiminde bir onaltılık renk girin." + } + } + } + }, + "alert.invalidColor.invalidMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" is not a valid hex color. Use #RRGGBB." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「%@」は有効な16進カラーではありません。#RRGGBB形式で入力してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" 不是有效的十六进制颜色。请使用 #RRGGBB 格式。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "「%@」不是有效的十六進位色碼。請使用 #RRGGBB 格式。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "\"%@\"은(는) 유효한 16진수 색상이 아닙니다. #RRGGBB 형식을 사용하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" ist keine gültige Hex-Farbe. Verwenden Sie #RRGGBB." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" no es un color hexadecimal válido. Usa #RRGGBB." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "« %@ » n'est pas une couleur hexadécimale valide. Utilisez le format #RRVVBB." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" non è un colore esadecimale valido. Usa #RRGGBB." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" er ikke en gyldig hex-farve. Brug #RRGGBB." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" nie jest prawidłowym kolorem szesnastkowym. Użyj #RRGGBB." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "«%@» не является допустимым цветом. Используйте формат #RRGGBB." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" nije važeća heksadecimalna boja. Koristite #RRGGBB." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" ليس لونًا سداسيًا صالحًا. استخدم #RRGGBB." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "«%@» er ikke en gyldig heksadesimal farge. Bruk #RRGGBB." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" não é uma cor hexadecimal válida. Use #RRGGBB." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" ไม่ใช่รหัสสี hex ที่ถูกต้อง ใช้ #RRGGBB" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" geçerli bir onaltılık renk değil. #RRGGBB kullanın." + } + } + } + }, + "alert.invalidColor.ok": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tamam" + } + } + } + }, + "alert.invalidColor.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid Color" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効なカラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無效的顏色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘못된 색상" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültige Farbe" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color no válido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur non valide" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore non valido" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ugyldig farve" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieprawidłowy kolor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недопустимый цвет" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nevažeća boja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون غير صالح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ugyldig farge" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor Inválida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีไม่ถูกต้อง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçersiz Renk" + } + } + } + }, + "alert.renameWorkspace.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Annulla" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Annuller" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Anuluj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отменить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkaži" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avbryt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Vazgeç" + } + } + } + }, + "alert.renameWorkspace.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a custom name for this workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このワークスペースのカスタム名を入力してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请为此工作区输入自定义名称。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為此工作區輸入自訂名稱。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 작업 공간의 사용자 지정 이름을 입력하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie einen benutzerdefinierten Namen für diesen Arbeitsbereich ein." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce un nombre personalizado para este espacio de trabajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez un nom personnalisé pour cet espace de travail." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci un nome personalizzato per questa area di lavoro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indtast et brugerdefineret navn til dette arbejdsområde." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wprowadź własną nazwę dla tej przestrzeni roboczej." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите пользовательское имя для этого рабочего пространства." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Unesite prilagođeni naziv za ovaj radni prostor." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل اسمًا مخصصًا لمساحة العمل هذه." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skriv inn et egendefinert navn for dette arbeidsområdet." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Insira um nome personalizado para esta área de trabalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป้อนชื่อที่กำหนดเองสำหรับเวิร์กสเปซนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu çalışma alanı için özel bir ad girin." + } + } + } + }, + "alert.renameWorkspace.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區名稱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Name des Arbeitsbereichs" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre del espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nome area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Navn på arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nazwa przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naziv radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Navn på arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nome da área de trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ชื่อเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı adı" + } + } + } + }, + "alert.renameWorkspace.rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名称変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Umbenennen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi nytt navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden Adlandır" + } + } + } + }, + "alert.renameWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの名称変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich umbenennen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi arbeidsområdet nytt navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Yeniden Adlandır" + } + } + } + }, + "appIcon.automatic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Automático" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Automatique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Automatica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Automatisk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Automatyczna" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматически" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Automatski" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تلقائي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Automatisk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Automático" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อัตโนมัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik" + } + } + } + }, + "appIcon.dark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダーク" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dunkel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Oscuro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sombre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scura" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mørk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ciemna" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Темная" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tamna" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "داكن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Mørk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escuro" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "มืด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Koyu" + } + } + } + }, + "appIcon.light": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "淺色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이트" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hell" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Claro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clair" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiara" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lys" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jasna" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Светлая" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Svijetla" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فاتح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lys" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Claro" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สว่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açık" + } + } + } + }, + "appearance.auto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Automático" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Automatico" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Automatisk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Automatyczny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Авто" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Automatski" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تلقائي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Automático" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อัตโนมัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik" + } + } + } + }, + "appearance.dark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダーク" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dunkel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Oscuro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sombre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scuro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mørk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ciemny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Темное" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tamna" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "داكن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Mørk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escuro" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "มืด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Koyu" + } + } + } + }, + "appearance.light": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "淺色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이트" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hell" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Claro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clair" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiaro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lys" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jasny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Светлое" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Svijetla" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فاتح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lys" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Claro" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สว่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açık" + } + } + } + }, + "appearance.system": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Systemowy" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системное" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sistemski" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النظام" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระบบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem" + } + } + } + }, + "browser.action.newTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "new tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Tab" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "nueva pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "nouvel onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "nuova scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "ny fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "nowa karta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "новая вкладка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "novi tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان جديد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "ny fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "nova aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "yeni sekme" + } + } + } + }, + "browser.addressBar.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search or enter URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索またはURLを入力" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索或输入 URL" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋或輸入 URL" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 또는 URL 입력" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchen oder URL eingeben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar o introducir URL" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher ou saisir une URL" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cerca o inserisci un URL" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg eller indtast URL" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szukaj lub wprowadź URL" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск или ввод URL" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pretražite ili unesite URL" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ابحث أو أدخل URL" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Søk eller skriv inn URL" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar ou digitar URL" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหาหรือป้อน URL" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Arayın veya URL girin" + } + } + } + }, + "browser.addressBarSuggestions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Address bar suggestions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アドレスバーの候補" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "地址栏建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網址列建議" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주소 표시줄 제안" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Adressleisten-Vorschläge" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sugerencias de la barra de direcciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suggestions de la barre d'adresse" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Suggerimenti barra degli indirizzi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forslag til adresselinjen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podpowiedzi paska adresu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсказки адресной строки" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prijedlozi adresne trake" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اقتراحات شريط العنوان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forslag i adressefeltet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sugestões da barra de endereço" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำแนะนำแถบที่อยู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Adres çubuğu önerileri" + } + } + } + }, + "browser.alwaysAllowHost": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always allow this host in cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このホストを cmux で常に許可" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "始终允许此主机在 cmux 中打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一律允許此主機在 cmux 中開啟" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux에서 이 호스트 항상 허용" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diesen Host immer in cmux zulassen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitir siempre este host en cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toujours autoriser cet hôte dans cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Consenti sempre questo host in cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tillad altid denne vært i cmux" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zawsze zezwalaj na ten host w cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всегда разрешать этот хост в cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uvijek dozvoli ovaj host u cmux" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "السماح دائمًا لهذا المضيف في cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Alltid tillat denne verten i cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sempre permitir este host no cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อนุญาตโฮสต์นี้ใน cmux เสมอ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu ana bilgisayarı cmux'ta her zaman izin ver" + } + } + } + }, + "browser.contextMenu.openLinkInDefaultBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Link in Default Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトブラウザでリンクを開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在默认浏览器中打开链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在預設瀏覽器中開啟連結" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 브라우저에서 링크 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Link im Standardbrowser öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir enlace en el navegador predeterminado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le lien dans le navigateur par défaut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri link nel browser predefinito" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn link i standardbrowser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz odnośnik w domyślnej przeglądarce" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть ссылку в браузере по умолчанию" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori link u podrazumijevanom pregledniku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الرابط في المتصفح الافتراضي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne lenke i standard nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Link no Navegador Padrão" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดลิงก์ในเบราว์เซอร์เริ่มต้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlantıyı Varsayılan Tarayıcıda Aç" + } + } + } + }, + "browser.contextMenu.openLinkInNewTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Link in New Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タブでリンクを開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在新标签页中打开链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在新標籤頁中開啟連結" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 탭에서 링크 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Link in neuem Tab öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir enlace en una nueva pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le lien dans un nouvel onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri link in una nuova scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn link i ny fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz odnośnik w nowej karcie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть ссылку в новой вкладке" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori link u novom tabu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الرابط في لسان جديد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne lenke i ny fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Link em Nova Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดลิงก์ในแท็บใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlantıyı Yeni Sekmede Aç" + } + } + } + }, + "browser.dialog.pageSays": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This page says:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このページの内容:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此页面显示:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此頁面顯示:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 페이지의 메시지:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Seite meldet:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta página dice:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette page indique :" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa pagina dice:" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne side siger:" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ta strona mówi:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сообщение на странице:" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ova stranica kaže:" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقول هذه الصفحة:" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne siden sier:" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Esta página diz:" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้านี้แจ้งว่า:" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu sayfa diyor ki:" + } + } + } + }, + "browser.dialog.pageSaysAt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The page at %@ says:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ %@ のメッセージ:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 页面显示:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 頁面顯示:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 페이지의 메시지:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Seite auf %@ meldet:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La página en %@ dice:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La page à %@ indique :" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La pagina su %@ dice:" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Siden på %@ siger:" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Strona pod adresem %@ mówi:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Страница %@ сообщает:" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Stranica na %@ kaže:" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقول الصفحة في %@:" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Siden på %@ sier:" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A página em %@ diz:" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าที่ %@ แจ้งว่า:" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ sayfası diyor ki:" + } + } + } + }, + "browser.downloadInProgress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download in progress" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード中" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载进行中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在下載" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 진행 중" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download läuft" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descarga en curso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargement en cours" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Download in corso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Download i gang" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pobieranie w toku" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка выполняется" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preuzimanje u toku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التنزيل قيد التقدم" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nedlasting pågår" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Download em andamento" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังดาวน์โหลด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İndirme devam ediyor" + } + } + } + }, + "browser.downloading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード中..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 중..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird heruntergeladen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargando..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargement..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Download in corso..." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Downloader…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pobieranie…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preuzimanje..." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التنزيل…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Laster ned …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Baixando..." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังดาวน์โหลด..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İndiriliyor..." + } + } + } + }, + "browser.error.cantOpen.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't open this page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このページを開けません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法打开此页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法開啟此頁面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 페이지를 열 수 없습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Seite kann nicht geöffnet werden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede abrir esta página" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'ouvrir cette page" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile aprire questa pagina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kan ikke åbne denne side" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie można otworzyć tej strony" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удается открыть эту страницу" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće otvoriti ovu stranicu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يمكن فتح هذه الصفحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kan ikke åpne denne siden" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível abrir esta página" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถเปิดหน้านี้ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu sayfa açılamıyor" + } + } + } + }, + "browser.error.cantReach.messageSite": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The site refused to connect. Check that a server is running on this address." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイトに接続できませんでした。このアドレスでサーバーが実行されていることを確認してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网站拒绝了连接。请检查此地址上是否有服务器正在运行。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網站拒絕連線。請確認此位址上有伺服器正在運行。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이트에서 연결을 거부했습니다. 이 주소에서 서버가 실행 중인지 확인하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Website hat die Verbindung verweigert. Überprüfen Sie, ob ein Server unter dieser Adresse läuft." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El sitio rechazó la conexión. Comprueba que haya un servidor en ejecución en esta dirección." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le site a refusé la connexion. Vérifiez qu'un serveur est bien en cours d'exécution à cette adresse." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il sito ha rifiutato la connessione. Verifica che un server sia in esecuzione su questo indirizzo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Siden nægtede forbindelsen. Kontroller at en server kører på denne adresse." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Serwer odmówił połączenia. Sprawdź, czy pod tym adresem działa serwer." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сайт отклонил подключение. Убедитесь, что сервер запущен по этому адресу." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Stranica je odbila vezu. Provjerite da li je server pokrenut na ovoj adresi." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رفض الموقع الاتصال. تأكد من تشغيل خادم على هذا العنوان." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettstedet nektet tilkobling. Kontroller at en server kjører på denne adressen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O site recusou a conexão. Verifique se há um servidor em execução neste endereço." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เว็บไซต์ปฏิเสธการเชื่อมต่อ ตรวจสอบว่ามีเซิร์ฟเวอร์ทำงานบนที่อยู่นี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Site bağlantıyı reddetti. Bu adreste bir sunucunun çalışıp çalışmadığını kontrol edin." + } + } + } + }, + "browser.error.cantReach.messageURL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ refused to connect. Check that a server is running on this address." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に接続できませんでした。このアドレスでサーバーが実行されていることを確認してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 拒绝了连接。请检查此地址上是否有服务器正在运行。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 拒絕連線。請確認此位址上有伺服器正在運行。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에서 연결을 거부했습니다. 이 주소에서 서버가 실행 중인지 확인하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ hat die Verbindung verweigert. Überprüfen Sie, ob ein Server unter dieser Adresse läuft." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ rechazó la conexión. Comprueba que haya un servidor en ejecución en esta dirección." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ a refusé la connexion. Vérifiez qu'un serveur est bien en cours d'exécution à cette adresse." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ ha rifiutato la connessione. Verifica che un server sia in esecuzione su questo indirizzo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@ nægtede forbindelsen. Kontroller at en server kører på denne adresse." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ odmówił połączenia. Sprawdź, czy pod tym adresem działa serwer." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ отклонил подключение. Убедитесь, что сервер запущен по этому адресу." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@ je odbio vezu. Provjerite da li je server pokrenut na ovoj adresi." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رفض %@ الاتصال. تأكد من تشغيل خادم على هذا العنوان." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@ nektet tilkobling. Kontroller at en server kjører på denne adressen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ recusou a conexão. Verifique se há um servidor em execução neste endereço." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@ ปฏิเสธการเชื่อมต่อ ตรวจสอบว่ามีเซิร์ฟเวอร์ทำงานบนที่อยู่นี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ bağlantıyı reddetti. Bu adreste bir sunucunun çalışıp çalışmadığını kontrol edin." + } + } + } + }, + "browser.error.cantReach.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't reach this page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このページに到達できません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法访问此页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法連線至此頁面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 페이지에 연결할 수 없습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Seite ist nicht erreichbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede acceder a esta página" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'atteindre cette page" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile raggiungere questa pagina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kan ikke nå denne side" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie można uzyskać dostępu do tej strony" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удается подключиться к странице" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće dosegnuti ovu stranicu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يمكن الوصول إلى هذه الصفحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kan ikke nå denne siden" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível acessar esta página" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถเข้าถึงหน้านี้ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu sayfaya ulaşılamıyor" + } + } + } + }, + "browser.error.checkNetwork": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check your network connection and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク接続を確認してもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请检查您的网络连接,然后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請檢查您的網路連線後再試一次。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크 연결을 확인하고 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprueba tu conexión de red e inténtalo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifiez votre connexion réseau et réessayez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Controlla la connessione di rete e riprova." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kontroller din netværksforbindelse, og prøv igen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdź połączenie sieciowe i spróbuj ponownie." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверьте сетевое подключение и повторите попытку." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Provjerite mrežnu vezu i pokušajte ponovo." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحقق من اتصال الشبكة وحاول مجددًا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kontroller nettverkstilkoblingen og prøv igjen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verifique sua conexão de rede e tente novamente." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตรวจสอบการเชื่อมต่อเครือข่ายแล้วลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ağ bağlantınızı kontrol edip tekrar deneyin." + } + } + } + }, + "browser.error.frameLoadInterrupted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Frame load interrupted" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フレームの読み込みが中断されました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页面框架加载中断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁框載入中斷" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프레임 로딩이 중단되었습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laden des Frames unterbrochen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carga del marco interrumpida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement du cadre interrompu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Caricamento del frame interrotto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indlæsning af ramme blev afbrudt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ładowanie ramki zostało przerwane" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка фрейма прервана" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Učitavanje okvira je prekinuto" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم قطع تحميل الإطار" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Innlasting av ramme ble avbrutt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Carregamento do frame interrompido" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การโหลดเฟรมถูกขัดจังหวะ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çerçeve yüklemesi kesildi" + } + } + } + }, + "browser.error.insecure.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in cmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は HTTP 接続を使用しているため、通信内容がネットワーク上で読み取られたり改ざんされる可能性があります。\n\nデフォルトブラウザで開くか、cmux で続行してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 使用纯 HTTP 连接,网络上的流量可能被读取或修改。\n\n请在默认浏览器中打开此 URL,或在 cmux 中继续访问。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 使用未加密的 HTTP,網路上的流量可能被讀取或竄改。\n\n在您的預設瀏覽器中開啟此 URL,或在 cmux 中繼續。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@은(는) 일반 HTTP를 사용하므로, 네트워크에서 트래픽이 읽히거나 변조될 수 있습니다.\n\n기본 브라우저에서 이 URL을 열거나, cmux에서 계속 진행하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ verwendet unverschlüsseltes HTTP, daher kann der Datenverkehr im Netzwerk gelesen oder verändert werden.\n\nÖffnen Sie diese URL in Ihrem Standardbrowser oder fahren Sie in cmux fort." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ usa HTTP sin cifrar, por lo que el tráfico puede ser leído o modificado en la red.\n\nAbre esta URL en tu navegador predeterminado o continúa en cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ utilise le protocole HTTP non chiffré, le trafic peut donc être lu ou modifié sur le réseau.\n\nOuvrez cette URL dans votre navigateur par défaut ou continuez dans cmux." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ utilizza HTTP non crittografato, quindi il traffico può essere letto o modificato sulla rete.\n\nApri questo URL nel browser predefinito oppure continua in cmux." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@ bruger almindelig HTTP, så trafikken kan læses eller ændres på netværket.\n\nÅbn denne URL i din standardbrowser, eller fortsæt i cmux." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ używa zwykłego HTTP, więc ruch sieciowy może być odczytany lub zmodyfikowany.\n\nOtwórz ten URL w domyślnej przeglądarce lub kontynuuj w cmux." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ использует незащищенный протокол HTTP, поэтому трафик может быть перехвачен или изменен в сети.\n\nОткройте этот URL в браузере по умолчанию или продолжите в cmux." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@ koristi čisti HTTP, pa se promet može čitati ili mijenjati na mreži.\n\nOtvorite ovaj URL u podrazumijevanom pregledniku ili nastavite u cmux." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستخدم %@ بروتوكول HTTP غير مشفر، لذا يمكن قراءة حركة البيانات أو تعديلها على الشبكة.\n\nافتح هذا URL في متصفحك الافتراضي، أو تابع في cmux." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@ bruker vanlig HTTP, så trafikk kan leses eller endres på nettverket.\n\nÅpne denne URL-en i standard nettleser, eller fortsett i cmux." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ usa HTTP simples, então o tráfego pode ser lido ou modificado na rede.\n\nAbra esta URL no seu navegador padrão ou continue no cmux." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@ ใช้ HTTP แบบธรรมดา ดังนั้นข้อมูลอาจถูกอ่านหรือแก้ไขบนเครือข่ายได้\n\nเปิด URL นี้ในเบราว์เซอร์เริ่มต้น หรือดำเนินการต่อใน cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ düz HTTP kullanıyor, bu nedenle trafik ağda okunabilir veya değiştirilebilir.\n\nBu URL'yi varsayılan tarayıcınızda açın ya da cmux'ta devam edin." + } + } + } + }, + "browser.error.insecure.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection isn't secure" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続は安全ではありません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连接不安全" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連線不安全" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결이 안전하지 않습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung ist nicht sicher" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La conexión no es segura" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La connexion n'est pas sécurisée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La connessione non è sicura" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forbindelsen er ikke sikker" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połączenie nie jest bezpieczne" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключение не защищено" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Veza nije sigurna" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاتصال غير آمن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilkoblingen er ikke sikker" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A conexão não é segura" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การเชื่อมต่อไม่ปลอดภัย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlantı güvenli değil" + } + } + } + }, + "browser.error.invalidCertificate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The certificate for this site is invalid." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このサイトの証明書が無効です。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此网站的证书无效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此網站的憑證無效。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 사이트의 인증서가 유효하지 않습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Zertifikat für diese Website ist ungültig." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El certificado de este sitio no es válido." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le certificat de ce site n'est pas valide." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il certificato per questo sito non è valido." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Certifikatet for denne side er ugyldigt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Certyfikat tej strony jest nieprawidłowy." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сертификат этого сайта недействителен." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Certifikat za ovu stranicu je nevažeći." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "شهادة هذا الموقع غير صالحة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sertifikatet for dette nettstedet er ugyldig." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O certificado deste site é inválido." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใบรับรองสำหรับเว็บไซต์นี้ไม่ถูกต้อง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu sitenin sertifikası geçersiz." + } + } + } + }, + "browser.error.noInternet": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No internet connection" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インターネット接続がありません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无互联网连接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有網際網路連線" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인터넷에 연결되어 있지 않습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Internetverbindung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin conexión a internet" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune connexion Internet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna connessione a internet" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen internetforbindelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak połączenia z internetem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет подключения к интернету" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nema internetske veze" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد اتصال بالإنترنت" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen internettforbindelse" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sem conexão com a internet" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีการเชื่อมต่ออินเทอร์เน็ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İnternet bağlantısı yok" + } + } + } + }, + "browser.error.reload": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로고침" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu laden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recargar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recharger" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ricarica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genindlæs" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odśwież" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезагрузить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo učitaj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تحميل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Last inn på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recarregar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหลดใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden Yükle" + } + } + } + }, + "browser.goBack": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Go Back" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻る" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "后退" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "뒤로" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Retroceder" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Page précédente" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Indietro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gå tilbage" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wstecz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Назад" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nazad" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رجوع" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gå tilbake" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Voltar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้อนกลับ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geri Git" + } + } + } + }, + "browser.goForward": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Go Forward" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "進む" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "前进" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앞으로" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vor" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Avanzar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Page suivante" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avanti" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gå frem" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do przodu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вперед" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naprijed" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقدم" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gå fremover" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Avançar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไปข้างหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İleri Git" + } + } + } + }, + "browser.goToURL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "go to URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "URLに移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "前往 URL" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "前往 URL" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "URL로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "URL aufrufen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "ir a URL" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "accéder à l'URL" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "vai all'URL" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "gå til URL" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "przejdź do URL" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "перейти по URL" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "idi na URL" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتقل إلى URL" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "gå til URL" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "ir para URL" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไปที่ URL" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "URL'ye git" + } + } + } + }, + "browser.newTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Tab" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ny fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa karta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новая вкладка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان جديد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ny fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni sekme" + } + } + } + }, + "browser.openInDefaultBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in Default Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトブラウザで開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在默认浏览器中打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在預設瀏覽器中開啟" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 브라우저에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Standardbrowser öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir en el navegador predeterminado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir dans le navigateur par défaut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri nel browser predefinito" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn i standardbrowser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz w domyślnej przeglądarce" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть в браузере по умолчанию" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori u podrazumijevanom pregledniku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح في المتصفح الافتراضي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne i standard nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir no Navegador Padrão" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดในเบราว์เซอร์เริ่มต้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Varsayılan Tarayıcıda Aç" + } + } + } + }, + "browser.proceedInCmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Proceed in cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux で続行" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 中继续" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 中繼續" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux에서 계속" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In cmux fortfahren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Continuar en cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Continuer dans cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Continua in cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fortsæt i cmux" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kontynuuj w cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Продолжить в cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nastavi u cmux" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المتابعة في cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fortsett i cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Continuar no cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ดำเนินการต่อใน cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux'ta devam et" + } + } + } + }, + "browser.reload": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로고침" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu laden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recargar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recharger" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ricarica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genindlæs" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odśwież" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезагрузить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo učitaj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تحميل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Last inn på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recarregar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหลดใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden Yükle" + } + } + } + }, + "browser.search.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cerca" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szukaj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pretraži" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بحث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Søk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ara" + } + } + } + }, + "browser.stop": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중단" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Stopp" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detener" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrêter" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Interrompi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zatrzymaj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стоп" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zaustavi" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Stopp" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Parar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หยุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durdur" + } + } + } + }, + "browser.switchToTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch to tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブに切替" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换到标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換至標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭으로 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Tab wechseln" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar a pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Basculer vers l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Passa alla scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skift til fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz na kartę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к вкладке" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci se na tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التبديل إلى اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bytt til fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar para aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับไปยังแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeye geç" + } + } + } + }, + "browser.toggleDevTools": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Developer Tools" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デベロッパツールを切替" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换开发者工具" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換開發者工具" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개발자 도구 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entwicklerwerkzeuge ein-/ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar herramientas de desarrollo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher/masquer les outils de développement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva Strumenti sviluppatore" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå udviklerværktøjer til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz narzędzia deweloperskie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Инструменты разработчика" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci razvojne alate" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل أدوات المطور" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå utviklerverktøy av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Ferramentas do Desenvolvedor" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับเครื่องมือนักพัฒนา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geliştirici Araçlarını Aç/Kapat" + } + } + } + }, + "cli.install.adminRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Administrator privileges were required to write to /usr/local/bin." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "/usr/local/binへの書き込みに管理者権限が必要でした。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "写入 /usr/local/bin 需要管理员权限。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "寫入 /usr/local/bin 需要管理者權限。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "/usr/local/bin에 기록하기 위해 관리자 권한이 필요합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Schreiben in /usr/local/bin waren Administratorrechte erforderlich." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se requirieron privilegios de administrador para escribir en /usr/local/bin." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Des privilèges d'administrateur étaient nécessaires pour écrire dans /usr/local/bin." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sono stati richiesti i privilegi di amministratore per scrivere in /usr/local/bin." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Administratorrettigheder var nødvendige for at skrive til /usr/local/bin." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do zapisu w /usr/local/bin wymagane były uprawnienia administratora." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для записи в /usr/local/bin потребовались права администратора." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Potrebne su administratorske privilegije za pisanje u /usr/local/bin." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "كانت صلاحيات المسؤول مطلوبة للكتابة في /usr/local/bin." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Administratorrettigheter var nødvendig for å skrive til /usr/local/bin." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Privilégios de administrador foram necessários para gravar em /usr/local/bin." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ต้องใช้สิทธิ์ผู้ดูแลระบบเพื่อเขียนไปยัง /usr/local/bin" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "/usr/local/bin dizinine yazmak için yönetici ayrıcalıkları gerekiyordu." + } + } + } + }, + "cli.install.symlinkCreated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Created symlink:\n\n%1$@ -> %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シンボリックリンクを作成しました:\n\n%1$@ -> %2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已创建符号链接:\n\n%1$@ -> %2$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已建立符號連結:\n\n%1$@ -> %2$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "심볼릭 링크 생성 완료:\n\n%1$@ -> %2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbolische Verknüpfung erstellt:\n\n%1$@ -> %2$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enlace simbólico creado:\n\n%1$@ -> %2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lien symbolique créé :\n\n%1$@ -> %2$@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Collegamento simbolico creato:\n\n%1$@ -> %2$@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Symbolsk link oprettet:\n%1$@ -> %2$@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Utworzono dowiązanie symboliczne:\n\n%1$@ -> %2$@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Создана символическая ссылка:\n\n%1$@ -> %2$@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kreirana simbolička veza:\n\n%1$@ -> %2$@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إنشاء رابط رمزي:\n\n%1$@ -> %2$@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Opprettet symbolsk lenke:\n\n%1$@ -> %2$@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Link simbólico criado:\n\n%1$@ -> %2$@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สร้างลิงก์สัญลักษณ์แล้ว:\n\n%1$@ -> %2$@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sembolik bağ oluşturuldu:\n\n%1$@ -> %2$@" + } + } + } + }, + "cli.installFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't Install cmux CLI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI をインストールできませんでした" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法安装 cmux CLI" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法安裝 cmux CLI" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI를 설치할 수 없습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI konnte nicht installiert werden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo instalar la CLI de cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'installer la CLI cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile installare la CLI di cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke installere cmux CLI" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się zainstalować CLI cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось установить cmux CLI" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće instalirati cmux CLI" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تثبيت واجهة أوامر cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke installere cmux CLI" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não Foi Possível Instalar o CLI do cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถติดตั้ง cmux CLI ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI Yüklenemedi" + } + } + } + }, + "cli.installed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI Installed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI がインストールされました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI 已安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI 已安裝" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI 설치 완료" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI installiert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "CLI de cmux instalada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "CLI cmux installée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "CLI di cmux installata" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI installeret" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "CLI cmux zainstalowane" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI установлен" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI instaliran" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تثبيت واجهة أوامر cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI installert" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "CLI do cmux Instalado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ติดตั้ง cmux CLI แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI Yüklendi" + } + } + } + }, + "cli.uninstall.adminRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Administrator privileges were required to modify /usr/local/bin." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "/usr/local/binの変更に管理者権限が必要でした。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修改 /usr/local/bin 需要管理员权限。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修改 /usr/local/bin 需要管理者權限。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "/usr/local/bin을 수정하기 위해 관리자 권한이 필요합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Ändern von /usr/local/bin waren Administratorrechte erforderlich." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se requirieron privilegios de administrador para modificar /usr/local/bin." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Des privilèges d'administrateur étaient nécessaires pour modifier /usr/local/bin." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sono stati richiesti i privilegi di amministratore per modificare /usr/local/bin." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Administratorrettigheder var nødvendige for at ændre /usr/local/bin." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do modyfikacji /usr/local/bin wymagane były uprawnienia administratora." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для изменения /usr/local/bin потребовались права администратора." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Potrebne su administratorske privilegije za izmjenu /usr/local/bin." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "كانت صلاحيات المسؤول مطلوبة لتعديل /usr/local/bin." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Administratorrettigheter var nødvendig for å endre /usr/local/bin." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Privilégios de administrador foram necessários para modificar /usr/local/bin." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ต้องใช้สิทธิ์ผู้ดูแลระบบเพื่อแก้ไข /usr/local/bin" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "/usr/local/bin dizinini değiştirmek için yönetici ayrıcalıkları gerekiyordu." + } + } + } + }, + "cli.uninstall.notFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No cmux CLI symlink was found at %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@にcmux CLIのシンボリックリンクが見つかりませんでした。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 %@ 处未找到 cmux CLI 符号链接。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 %@ 找不到 cmux CLI 的符號連結。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에서 cmux CLI 심볼릭 링크를 찾을 수 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es wurde keine symbolische Verknüpfung für cmux CLI unter %@ gefunden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró ningún enlace simbólico de la CLI de cmux en %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun lien symbolique de la CLI cmux n'a été trouvé à l'emplacement %@." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun collegamento simbolico della CLI di cmux trovato in %@." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Intet cmux CLI symbolsk link blev fundet på %@." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono dowiązania symbolicznego CLI cmux pod adresem %@." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Символическая ссылка cmux CLI не найдена по пути %@." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Simbolička veza cmux CLI nije pronađena na %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على رابط رمزي لواجهة أوامر cmux في %@." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen cmux CLI-symbolsk lenke ble funnet på %@." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhum link simbólico do CLI do cmux foi encontrado em %@." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบลิงก์สัญลักษณ์ cmux CLI ที่ %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ konumunda cmux CLI sembolik bağı bulunamadı." + } + } + } + }, + "cli.uninstall.removed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Removed %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@を削除しました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已移除 %@。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已移除 %@。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@을(를) 제거했습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ wurde entfernt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se eliminó %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ a été supprimé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimosso %@." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernede %@." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usunięto %@." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалено: %@." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uklonjeno %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمت إزالة %@." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjernet %@." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ removido." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ %@ แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ kaldırıldı." + } + } + } + }, + "cli.uninstallFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't Uninstall cmux CLI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI をアンインストールできませんでした" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法卸载 cmux CLI" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法解除安裝 cmux CLI" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI를 제거할 수 없습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI konnte nicht deinstalliert werden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo desinstalar la CLI de cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de désinstaller la CLI cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile disinstallare la CLI di cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke afinstallere cmux CLI" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się odinstalować CLI cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось удалить cmux CLI" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće deinstalirati cmux CLI" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر إلغاء تثبيت واجهة أوامر cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke avinstallere cmux CLI" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não Foi Possível Desinstalar o CLI do cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถถอนการติดตั้ง cmux CLI ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI Kaldırılamadı" + } + } + } + }, + "cli.uninstalled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI Uninstalled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI がアンインストールされました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI 已卸载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI 已解除安裝" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI 제거 완료" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI deinstalliert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "CLI de cmux desinstalada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "CLI cmux désinstallée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "CLI di cmux disinstallata" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI afinstalleret" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "CLI cmux odinstalowane" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI удален" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI deinstaliran" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إلغاء تثبيت واجهة أوامر cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI avinstallert" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "CLI do cmux Desinstalado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ถอนการติดตั้ง cmux CLI แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux CLI Kaldırıldı" + } + } + } + }, + "command.applyUpdateIfAvailable.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グローバル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전역" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Général" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Globale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Globalne" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Глобальные" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Globalno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عام" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทั่วไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Genel" + } + } + } + }, + "command.applyUpdateIfAvailable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apply Update (If Available)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを適用(利用可能な場合)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用更新(如果可用)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "套用更新(如果有的話)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 적용 (사용 가능한 경우)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update anwenden (falls verfügbar)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicar actualización (si está disponible)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appliquer la mise à jour (si disponible)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Applica aggiornamento (se disponibile)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Anvend opdatering (hvis tilgængelig)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zastosuj aktualizację (jeśli dostępna)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Применить обновление (если доступно)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Primijeni ažuriranje (ako je dostupno)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تطبيق التحديث (إن توفر)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Installer oppdatering (hvis tilgjengelig)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aplicar Atualização (Se Disponível)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ติดตั้งอัปเดต (ถ้ามี)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeyi Uygula (Varsa)" + } + } + } + }, + "command.attemptUpdate.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グローバル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전역" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Général" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Globale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Globalne" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Глобальные" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Globalno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عام" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทั่วไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Genel" + } + } + } + }, + "command.attemptUpdate.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attempt Update" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを試行" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尝试更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "嘗試更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 시도" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update versuchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Intentar actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tenter la mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tenta aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forsøg opdatering" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spróbuj zaktualizować" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Попытаться обновить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokušaj ažuriranje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "محاولة التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forsøk oppdatering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tentar Atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลองอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeyi Dene" + } + } + } + }, + "command.browserBack.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻る" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "后退" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "뒤로" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Atrás" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Précédent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Indietro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilbage" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wstecz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Назад" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nazad" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رجوع" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilbake" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Voltar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้อนกลับ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geri" + } + } + } + }, + "command.browserClearHistory.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przeglądarka" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браузер" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preglednik" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı" + } + } + } + }, + "command.browserClearHistory.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Browser History" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ履歴をクリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除浏览器历史记录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除瀏覽記錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 기록 지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browserverlauf löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar historial del navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer l'historique du navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella cronologia browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd browserhistorik" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść historię przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить историю браузера" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši historiju preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح سجل المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tøm nettleserhistorikk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Histórico do Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างประวัติเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı Geçmişini Temizle" + } + } + } + }, + "command.browserConsole.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show JavaScript Console" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "JavaScriptコンソールを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示 JavaScript 控制台" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示 JavaScript 主控台" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "JavaScript 콘솔 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "JavaScript-Konsole anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar consola de JavaScript" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la console JavaScript" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra console JavaScript" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis JavaScript-konsol" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż konsolę JavaScript" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать консоль JavaScript" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži JavaScript konzolu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض وحدة تحكم JavaScript" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis JavaScript-konsoll" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Console JavaScript" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงคอนโซล JavaScript" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "JavaScript Konsolunu Göster" + } + } + } + }, + "command.browserDuplicateRight.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser-Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición del navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition du navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browserlayout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет браузера" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleseroppsett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout do Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครงเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı Düzeni" + } + } + } + }, + "command.browserDuplicateRight.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Duplicate Browser to the Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを右に複製" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右复制浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將瀏覽器複製到右側" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저를 오른쪽으로 복제" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser nach rechts duplizieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Duplicar navegador a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dupliquer le navigateur à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Duplica browser a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dupliker browser til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Duplikuj przeglądarkę na prawo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дублировать браузер вправо" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dupliciraj preglednik desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ المتصفح إلى اليمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dupliser nettleser til høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Duplicar Navegador à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำซ้ำเบราว์เซอร์ไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Sağa Çoğalt" + } + } + } + }, + "command.browserFocusAddressBar.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Address Bar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アドレスバーにフォーカス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聚焦地址栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聚焦網址列" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주소 표시줄 포커스" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Adressleiste fokussieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enfocar barra de direcciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer la barre d'adresse" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Porta il focus sulla barra degli indirizzi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fokuser adresselinjen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ustaw fokus na pasku adresu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к адресной строке" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Fokusiraj adresnu traku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التركيز على شريط العنوان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fokuser adressefeltet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Focar na Barra de Endereço" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฟกัสแถบที่อยู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Adres Çubuğuna Odaklan" + } + } + } + }, + "command.browserForward.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Forward" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "進む" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "前进" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앞으로" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vor" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Adelante" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avanti" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Frem" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do przodu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вперед" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naprijed" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقدم" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fremover" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Avançar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไปข้างหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İleri" + } + } + } + }, + "command.browserOpenDefault.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Page in Default Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のページをデフォルトブラウザで開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在默认浏览器中打开当前页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在預設瀏覽器中開啟目前頁面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 페이지를 기본 브라우저에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelle Seite im Standardbrowser öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir página actual en el navegador predeterminado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir la page dans le navigateur par défaut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la pagina corrente nel browser predefinito" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn aktuel side i standardbrowser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżącą stronę w domyślnej przeglądarce" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущую страницу в браузере по умолчанию" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutnu stranicu u podrazumijevanom pregledniku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الصفحة الحالية في المتصفح الافتراضي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende side i standard nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Página Atual no Navegador Padrão" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดหน้าปัจจุบันในเบราว์เซอร์เริ่มต้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Sayfayı Varsayılan Tarayıcıda Aç" + } + } + } + }, + "command.browserReload.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload Page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページを再読み込み" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入頁面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 새로고침" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seite neu laden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recargar página" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recharger la page" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ricarica pagina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genindlæs side" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odśwież stronę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезагрузить страницу" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo učitaj stranicu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تحميل الصفحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Last inn siden på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recarregar Página" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหลดหน้าใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sayfayı Yeniden Yükle" + } + } + } + }, + "command.browserSplitDown.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser-Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición del navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition du navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browserlayout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет браузера" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleseroppsett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout do Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครงเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı Düzeni" + } + } + } + }, + "command.browserSplitDown.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Browser Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを下に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向下拆分浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向下分割瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저를 아래로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser nach unten teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir navegador hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser le navigateur vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi browser in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel browser nedad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel przeglądarkę w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить браузер вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli preglednik dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم المتصفح للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del nettleser ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir Navegador para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกเบราว์เซอร์ลงล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Aşağı Böl" + } + } + } + }, + "command.browserSplitRight.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser-Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición del navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition du navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browserlayout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет браузера" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleseroppsett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout do Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครงเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı Düzeni" + } + } + } + }, + "command.browserSplitRight.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Browser Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを右に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右拆分浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右分割瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저를 오른쪽으로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser nach rechts teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir navegador a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser le navigateur à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi browser a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel browser til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel przeglądarkę w prawo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить браузер вправо" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli preglednik desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم المتصفح لليمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del nettleser til høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir Navegador à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกเบราว์เซอร์ไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Sağa Böl" + } + } + } + }, + "command.browserToggleDevTools.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Developer Tools" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デベロッパツールの切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换开发者工具" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換開發者工具" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개발자 도구 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entwicklerwerkzeuge ein-/ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar herramientas de desarrollo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher/masquer les outils de développement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva Strumenti sviluppatore" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå udviklerværktøjer til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz narzędzia deweloperskie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Инструменты разработчика" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci razvojne alate" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل أدوات المطور" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå utviklerverktøy av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Ferramentas do Desenvolvedor" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับเครื่องมือนักพัฒนา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geliştirici Araçlarını Aç/Kapat" + } + } + } + }, + "command.browserZoomIn.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zoom In" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡大" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "放大" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "放大" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확대" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einzoomen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ampliar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Zoom avant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ingrandisci" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Zoom ind" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiększ" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Увеличить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uvećaj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تكبير" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Zoom inn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aumentar Zoom" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ซูมเข้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yakınlaştır" + } + } + } + }, + "command.browserZoomOut.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zoom Out" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "縮小" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮小" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "축소" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auszoomen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reducir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Zoom arrière" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riduci" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Zoom ud" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pomniejsz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уменьшить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Umanji" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تصغير" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Zoom ut" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Diminuir Zoom" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ซูมออก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzaklaştır" + } + } + } + }, + "command.browserZoomReset.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Actual Size" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実際のサイズ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实际大小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "實際大小" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실제 크기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Originalgröße" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tamaño real" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille réelle" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dimensione reale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Faktisk størrelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozmiar rzeczywisty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фактический размер" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Stvarna veličina" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحجم الفعلي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Faktisk størrelse" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tamanho Real" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดจริง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gerçek Boyut" + } + } + } + }, + "command.checkForUpdates.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グローバル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전역" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Général" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Globale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Globalne" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Глобальные" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Globalno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عام" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทั่วไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Genel" + } + } + } + }, + "command.checkForUpdates.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを確認" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 확인" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach Updates suchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar actualizaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher des mises à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Verifica aggiornamenti" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg efter opdateringer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdź aktualizacje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверить обновления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Provjeri ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق من التحديثات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Se etter oppdateringer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar Atualizações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตรวจหาอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeleri Denetle" + } + } + } + }, + "command.clearTabName.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Tab Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ名をクリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除标签页名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除標籤頁名稱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 이름 지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab-Name löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar nombre de pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer le nom de l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella nome scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd fanenavn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść nazwę karty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить имя вкладки" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši naziv taba" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح اسم اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern fanenavn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Nome da Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างชื่อแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme Adını Temizle" + } + } + } + }, + "command.clearWorkspaceName.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Workspace Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名をクリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除工作区名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除工作區名稱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름 지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereichsname löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar nombre del espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer le nom de l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella nome area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd arbejdsområdenavn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść nazwę przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить имя рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši naziv radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح اسم مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern arbeidsområdenavn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Nome da Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างชื่อเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı Adını Temizle" + } + } + } + }, + "command.closeTab.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вкладка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme" + } + } + } + }, + "command.closeTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij kartę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть вкладку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Kapat" + } + } + } + }, + "command.closeWindow.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere" + } + } + } + }, + "command.closeWindow.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer la fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق النافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencereyi Kapat" + } + } + } + }, + "command.closeWorkspace.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı" + } + } + } + }, + "command.closeWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij przestrzeń roboczą" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Kapat" + } + } + } + }, + "command.equalizeSplits.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Equalize Splits" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割を均等にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "均分面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "均等分割" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "분할 균등화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teilungen angleichen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Igualar divisiones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Égaliser les divisions" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Equalizza divisioni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Udlign opdelinger" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyrównaj podziały" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выровнять разделение" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Izjednači podjele" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تسوية التقسيمات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjør delinger like" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Equalizar Divisões" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปรับขนาดช่องแยกให้เท่ากัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölmeleri Eşitle" + } + } + } + }, + "command.installCLI.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "واجهة الأوامر" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + } + } + }, + "command.installCLI.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shell Command: Install 'cmux' in PATH" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シェルコマンド: 'cmux'をPATHにインストール" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Shell 命令:将 'cmux' 安装到 PATH" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Shell 指令:將「cmux」安裝至 PATH" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "셸 명령어: PATH에 'cmux' 설치" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Shell-Befehl: 'cmux' im PATH installieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comando de shell: Instalar 'cmux' en PATH" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commande Shell : installer « cmux » dans le PATH" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Comando shell: installa 'cmux' nel PATH" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skalkommando: Installer 'cmux' i PATH" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Polecenie powłoki: Zainstaluj „cmux” w PATH" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Команда оболочки: установить «cmux» в PATH" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Shell naredba: Instaliraj 'cmux' u PATH" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أمر الصدفة: تثبيت 'cmux' في PATH" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skallkommando: Installer «cmux» i PATH" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Comando Shell: Instalar 'cmux' no PATH" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำสั่ง Shell: ติดตั้ง 'cmux' ใน PATH" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kabuk Komutu: 'cmux'u PATH'e Yükle" + } + } + } + }, + "command.jumpUnread.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimler" + } + } + } + }, + "command.jumpUnread.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to Latest Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の未読にジャンプ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳转到最新未读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跳至最新未讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신 읽지 않은 항목으로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zur letzten ungelesenen springen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ir a la última no leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aller au dernier message non lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vai all'ultimo non letto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gå til seneste ulæste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przejdź do najnowszego nieprzeczytanego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к последнему непрочитанному" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Skoči na najnovije nepročitano" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتقال إلى أحدث غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gå til siste uleste" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ir para Última Não Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้ามไปยังรายการยังไม่อ่านล่าสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Son Okunmamışa Atla" + } + } + } + }, + "command.markTabRead.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark Tab as Read" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを既読にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将标签页标记为已读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將標籤頁標為已讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭을 읽음으로 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab als gelesen markieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Marcar pestaña como leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Marquer l'onglet comme lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segna scheda come letta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Marker fane som læst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oznacz kartę jako przeczytaną" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить вкладку как прочитанную" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi tab kao pročitan" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليم اللسان كمقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Merk fane som lest" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Marcar Aba como Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำเครื่องหมายแท็บว่าอ่านแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Okundu Olarak İşaretle" + } + } + } + }, + "command.markTabUnread.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark Tab as Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを未読にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将标签页标记为未读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將標籤頁標為未讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭을 읽지 않음으로 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab als ungelesen markieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Marcar pestaña como no leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Marquer l'onglet comme non lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segna scheda come non letta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Marker fane som ulæst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oznacz kartę jako nieprzeczytaną" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить вкладку как непрочитанную" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi tab kao nepročitan" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليم اللسان كغير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Merk fane som ulest" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Marcar Aba como Não Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำเครื่องหมายแท็บว่ายังไม่อ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Okunmadı Olarak İşaretle" + } + } + } + }, + "command.newBrowserTab.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вкладка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme" + } + } + } + }, + "command.newBrowserTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Tab (Browser)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タブ(ブラウザ)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建标签页(浏览器)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增標籤頁(瀏覽器)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 탭 (브라우저)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Tab (Browser)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva pestaña (Navegador)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel onglet (navigateur)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova scheda (browser)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ny fane (browser)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa karta (Przeglądarka)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новая вкладка (Браузер)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi tab (Preglednik)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان جديد (متصفح)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ny fane (nettleser)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Aba (Navegador)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บใหม่ (เบราว์เซอร์)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Sekme (Tarayıcı)" + } + } + } + }, + "command.newTerminalTab.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вкладка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme" + } + } + } + }, + "command.newTerminalTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Tab (Terminal)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タブ(ターミナル)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建标签页(终端)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增標籤頁(終端機)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 탭 (터미널)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Tab (Terminal)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva pestaña (Terminal)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel onglet (terminal)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova scheda (terminale)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ny fane (terminal)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa karta (Terminal)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новая вкладка (Терминал)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi tab (Terminal)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان جديد (طرفية)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ny fane (terminal)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Aba (Terminal)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บใหม่ (เทอร์มินัล)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Sekme (Terminal)" + } + } + } + }, + "command.newWindow.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere" + } + } + } + }, + "command.newWindow.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neues Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowe okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่างใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Pencere" + } + } + } + }, + "command.newWorkspace.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı" + } + } + } + }, + "command.newWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Çalışma Alanı" + } + } + } + }, + "command.nextTabInPane.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab Navigation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブナビゲーション" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页导航" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁導覽" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 탐색" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab-Navigation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Navegación de pestañas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigation par onglets" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Navigazione schede" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fanenavigation" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nawigacja po kartach" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Навигация по вкладкам" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Navigacija tabovima" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التنقل بين الألسنة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fanenavigering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Navegação de Abas" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การนำทางแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme Gezinme" + } + } + } + }, + "command.nextTabInPane.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next Tab in Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペイン内の次のタブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一个面板标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "面板中的下一個標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "패널 내 다음 탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächster Tab im Bereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Siguiente pestaña en el panel" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet suivant dans le panneau" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda successiva nel pannello" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Næste fane i panel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Następna karta w panelu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следующая вкладка в панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sljedeći tab u panelu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اللسان التالي في اللوحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Neste fane i panelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Próxima Aba no Painel" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บถัดไปในบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölmedeki Sonraki Sekme" + } + } + } + }, + "command.nextWorkspace.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace Navigation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースナビゲーション" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区导航" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區導覽" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 탐색" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich-Navigation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Navegación de espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigation par espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Navigazione aree di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområdenavigation" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nawigacja po przestrzeniach roboczych" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Навигация по рабочим пространствам" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Navigacija radnim prostorima" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التنقل بين مساحات العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområdenavigering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Navegação de Áreas de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การนำทางเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı Gezinme" + } + } + } + }, + "command.nextWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一个工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一個工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächster Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Siguiente espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail suivant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro successiva" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Næste arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Następna przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следующее рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sljedeći radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل التالية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Neste arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Próxima Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonraki Çalışma Alanı" + } + } + } + }, + "command.openFolder.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı" + } + } + } + }, + "command.openFolder.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Folder…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを開く…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开文件夹..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟資料夾..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 열기…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordner öffnen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir carpeta…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir un dossier..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri cartella…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn mappe…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz folder…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть папку..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori folder…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح مجلد…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne mappe …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Pasta…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดโฟลเดอร์..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasör Aç…" + } + } + } + }, + "command.openSettings.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グローバル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전역" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Général" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Globale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Globalne" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Глобальные" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Globalno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عام" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทั่วไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Genel" + } + } + } + }, + "command.openSettings.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir ajustes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les réglages" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri impostazioni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn indstillinger" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz ustawienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть настройки" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori postavke" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الإعدادات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne innstillinger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Ajustes" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดการตั้งค่า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarları Aç" + } + } + } + }, + "command.openWorkspacePRLinks.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open All Workspace PR Links" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースのPRリンクをすべて開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开工作区所有 PR 链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟所有工作區 PR 連結" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 PR 링크 모두 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle PR-Links des Arbeitsbereichs öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir todos los enlaces de PR del espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir tous les liens PR de l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri tutti i link PR dell'area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn alle PR-links for arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz wszystkie linki PR przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть все ссылки PR рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori sve PR linkove radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح جميع روابط طلبات السحب في مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne alle PR-lenker for arbeidsområdet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Todos os Links de PR da Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดลิงก์ PR ทั้งหมดของเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tüm Çalışma Alanı PR Bağlantılarını Aç" + } + } + } + }, + "command.pinTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pin Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブをピンで固定" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "固定标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "釘選標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 고정" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab anheften" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fijar pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épingler l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fissa scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fastgør fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przypnij kartę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепить вкладку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zakači tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fest fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fixar Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปักหมุดแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Sabitle" + } + } + } + }, + "command.pinWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pin Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースをピンで固定" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "固定工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "釘選工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 고정" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich anheften" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fijar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épingler l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fissa area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fastgør arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przypnij przestrzeń roboczą" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепить рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zakači radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fest arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fixar Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปักหมุดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Sabitle" + } + } + } + }, + "command.previousTabInPane.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab Navigation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブナビゲーション" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页导航" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁導覽" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 탐색" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab-Navigation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Navegación de pestañas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigation par onglets" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Navigazione schede" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fanenavigation" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nawigacja po kartach" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Навигация по вкладкам" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Navigacija tabovima" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التنقل بين الألسنة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fanenavigering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Navegação de Abas" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การนำทางแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme Gezinme" + } + } + } + }, + "command.previousTabInPane.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous Tab in Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペイン内の前のタブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一个面板标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "面板中的上一個標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "패널 내 이전 탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorheriger Tab im Bereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pestaña anterior en el panel" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet précédent dans le panneau" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda precedente nel pannello" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forrige fane i panel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Poprzednia karta w panelu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущая вкладка в панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prethodni tab u panelu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اللسان السابق في اللوحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forrige fane i panelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aba Anterior no Painel" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บก่อนหน้าในบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölmedeki Önceki Sekme" + } + } + } + }, + "command.previousWorkspace.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace Navigation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースナビゲーション" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区导航" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區導覽" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 탐색" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich-Navigation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Navegación de espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigation par espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Navigazione aree di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområdenavigation" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nawigacja po przestrzeniach roboczych" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Навигация по рабочим пространствам" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Navigacija radnim prostorima" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التنقل بين مساحات العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområdenavigering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Navegação de Áreas de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การนำทางเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı Gezinme" + } + } + } + }, + "command.previousWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前のワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一个工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一個工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorheriger Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail précédent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro precedente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forrige arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Poprzednia przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущее рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prethodni radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل السابقة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forrige arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho Anterior" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซก่อนหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önceki Çalışma Alanı" + } + } + } + }, + "command.renameTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Tab…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブの名称変更…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名标签页..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名標籤頁..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 이름 변경…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab umbenennen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar pestaña…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'onglet..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina scheda…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb fane…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę karty…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать вкладку..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj tab…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية اللسان…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi fanen nytt navn …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Aba…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อแท็บ..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Yeniden Adlandır…" + } + } + } + }, + "command.renameWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Workspace…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの名称変更…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名工作区..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名工作區..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름 변경…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich umbenennen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar espacio de trabajo…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'espace de travail..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina area di lavoro…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb arbejdsområde…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę przestrzeni roboczej…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать рабочее пространство..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj radni prostor…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية مساحة العمل…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi arbeidsområdet nytt navn …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Área de Trabalho…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อเวิร์กสเปซ..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Yeniden Adlandır…" + } + } + } + }, + "command.reopenClosedBrowserTab.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przeglądarka" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браузер" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preglednik" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı" + } + } + } + }, + "command.reopenClosedBrowserTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reopen Closed Browser Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じたブラウザタブを再度開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新打开已关闭的浏览器标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新開啟已關閉的瀏覽器標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫은 브라우저 탭 다시 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschlossenen Browser-Tab erneut öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reabrir pestaña del navegador cerrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rouvrir l'onglet de navigateur fermé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riapri scheda browser chiusa" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genåbn lukket browserfane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz ponownie zamkniętą kartę przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть закрытую вкладку браузера" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo otvori zatvoreni tab preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة فتح لسان المتصفح المغلق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne lukket nettleserfane på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reabrir Aba do Navegador Fechada" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดแท็บเบราว์เซอร์ที่ปิดไปอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapatılan Tarayıcı Sekmesini Yeniden Aç" + } + } + } + }, + "command.restartSocketListener.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グローバル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전역" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Général" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Globale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Globalne" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Глобальные" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Globalno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عام" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Globalt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทั่วไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Genel" + } + } + } + }, + "command.restartSocketListener.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart CLI Listener" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CLIリスナーを再起動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启 CLI 监听器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動 CLI 監聽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "CLI 리스너 재시작" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "CLI-Listener neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar receptor de CLI" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer l'écouteur CLI" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia listener CLI" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart CLI-lytter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie nasłuchiwanie CLI" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить прослушиватель CLI" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokreni CLI osluškivač" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تشغيل مستمع واجهة الأوامر" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start CLI-lytteren på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar Listener do CLI" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ตตัวรับฟัง CLI" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "CLI Dinleyicisini Yeniden Başlat" + } + } + } + }, + "command.showNotifications.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimler" + } + } + } + }, + "command.showNotifications.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimleri Göster" + } + } + } + }, + "command.terminalFind.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查找..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尋找..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "찾기…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trova…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Znajdź…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Найти..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pronađi…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بحث…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Finn …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหา..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bul…" + } + } + } + }, + "command.terminalFindNext.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find Next" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次を検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查找下一个" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尋找下一個" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 찾기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weitersuchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar siguiente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat suivant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trova successivo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Find næste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Znajdź następny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Найти далее" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pronađi sljedeće" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث عن التالي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Finn neste" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar Próximo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหาถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonrakini Bul" + } + } + } + }, + "command.terminalFindPrevious.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find Previous" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前を検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查找上一个" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尋找上一個" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 찾기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorheriges suchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat précédent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trova precedente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Find forrige" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Znajdź poprzedni" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Найти ранее" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pronađi prethodno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث عن السابق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Finn forrige" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar Anterior" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหาก่อนหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Öncekini Bul" + } + } + } + }, + "command.terminalHideFind.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Find Bar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索バーを非表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏查找栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏尋找列" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "찾기 막대 숨기기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchleiste ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar barra de búsqueda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer la barre de recherche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nascondi barra di ricerca" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skjul søgelinje" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ukryj pasek wyszukiwania" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть панель поиска" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sakrij traku za pretragu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخفاء شريط البحث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skjul søkelinje" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ocultar Barra de Busca" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ซ่อนแถบค้นหา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Arama Çubuğunu Gizle" + } + } + } + }, + "command.terminalSplitBrowserDown.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout terminale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Terminallayout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ terminala" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет терминала" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored terminala" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط الطرفية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Terminaloppsett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout do Terminal" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครงเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Düzeni" + } + } + } + }, + "command.terminalSplitBrowserDown.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Browser Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを下に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向下拆分浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向下分割瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저를 아래로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser nach unten teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir navegador hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser le navigateur vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi browser in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel browser nedad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel przeglądarkę w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить браузер вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli preglednik dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم المتصفح للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del nettleser ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir Navegador para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกเบราว์เซอร์ลงล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Aşağı Böl" + } + } + } + }, + "command.terminalSplitBrowserRight.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout terminale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Terminallayout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ terminala" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет терминала" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored terminala" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط الطرفية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Terminaloppsett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout do Terminal" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครงเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Düzeni" + } + } + } + }, + "command.terminalSplitBrowserRight.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Browser Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを右に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右拆分浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右分割瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저를 오른쪽으로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser nach rechts teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir navegador a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser le navigateur à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi browser a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel browser til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel przeglądarkę w prawo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить браузер вправо" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli preglednik desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم المتصفح لليمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del nettleser til høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir Navegador à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกเบราว์เซอร์ไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Sağa Böl" + } + } + } + }, + "command.terminalSplitDown.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout terminale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Terminallayout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ terminala" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет терминала" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored terminala" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط الطرفية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Terminaloppsett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout do Terminal" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครงเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Düzeni" + } + } + } + }, + "command.terminalSplitDown.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向下拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向下分割" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach unten teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel nedad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกลงล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aşağı Böl" + } + } + } + }, + "command.terminalSplitRight.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout terminale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Terminallayout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ terminala" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет терминала" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored terminala" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط الطرفية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Terminaloppsett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout do Terminal" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครงเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Düzeni" + } + } + } + }, + "command.terminalSplitRight.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右分割" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽으로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach rechts teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel w prawo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить вправо" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم لليمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del til høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sağa Böl" + } + } + } + }, + "command.terminalUseSelectionForFind.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Selection for Find" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択範囲を検索に使用" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用选中内容查找" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用所選範圍來尋找" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택 항목을 찾기에 사용" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auswahl für Suche verwenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usar selección para buscar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utiliser la sélection pour la recherche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Usa selezione per la ricerca" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brug markering til søgning" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użyj zaznaczenia do wyszukiwania" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использовать выделение для поиска" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Koristi odabrano za pretragu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدام التحديد للبحث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk utvalg for søk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Usar Seleção para Busca" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้ข้อความที่เลือกเพื่อค้นหา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Seçimi Bulmak İçin Kullan" + } + } + } + }, + "command.toggleFullScreen.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere" + } + } + } + }, + "command.toggleFullScreen.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Full Screen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルスクリーンの切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换全屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換全螢幕" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 화면 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollbild umschalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar pantalla completa" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer/désactiver le plein écran" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva schermo intero" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skift fuldskærm" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz pełny ekran" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полноэкранный режим" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci puni ekran" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل ملء الشاشة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå fullskjerm av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Tela Cheia" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับเต็มหน้าจอ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tam Ekranı Aç/Kapat" + } + } + } + }, + "command.toggleSidebar.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Layout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التخطيط" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppsett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düzen" + } + } + } + }, + "command.toggleSidebar.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーの切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换侧边栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換側邊欄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste ein-/ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher/masquer la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå sidebjælke til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz pasek boczny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Боковая панель" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci bočnu traku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå sidepanelet av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunu Aç/Kapat" + } + } + } + }, + "command.toggleSplitZoom.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout terminale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Terminallayout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ terminala" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет терминала" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored terminala" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط الطرفية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Terminaloppsett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout do Terminal" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครงเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Düzeni" + } + } + } + }, + "command.toggleSplitZoom.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Pane Zoom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインズームの切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换面板缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換面板縮放" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "패널 확대/축소 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereichszoom umschalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar zoom del panel" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer/désactiver le zoom du panneau" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva zoom pannello" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå panelzoom til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz powiększenie panelu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Масштаб панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci uvećanje panela" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل تكبير اللوحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå panelzoom av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Zoom do Painel" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับการซูมบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölme Yakınlaştırmasını Aç/Kapat" + } + } + } + }, + "command.triggerFlash.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "视图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示方式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ansicht" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vista" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Présentation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vista" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Visning" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Widok" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вид" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaz" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "العرض" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Visualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "มุมมอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Görünüm" + } + } + } + }, + "command.triggerFlash.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Flash Focused Panel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカスペインを強調" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "闪烁聚焦面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閃爍聚焦面板" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포커스된 패널 깜빡이기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokussierten Bereich hervorheben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resaltar panel enfocado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Flasher le panneau actif" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Evidenzia pannello attivo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fremhæv fokuseret panel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podświetl aktywny panel" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсветить активную панель" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi fokusirani panel" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وميض اللوحة المركّزة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Blink fokusert panel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Piscar Painel em Foco" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กะพริบแผงที่โฟกัส" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Odaklanılan Paneli Yanıp Söndür" + } + } + } + }, + "command.uninstallCLI.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "واجهة الأوامر" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + } + } + }, + "command.uninstallCLI.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shell Command: Uninstall 'cmux' from PATH" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シェルコマンド: 'cmux'をPATHからアンインストール" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Shell 命令:从 PATH 卸载 'cmux'" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Shell 指令:從 PATH 解除安裝「cmux」" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "셸 명령어: PATH에서 'cmux' 제거" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Shell-Befehl: 'cmux' aus PATH entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comando de shell: Desinstalar 'cmux' de PATH" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commande Shell : désinstaller « cmux » du PATH" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Comando shell: disinstalla 'cmux' dal PATH" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skalkommando: Afinstaller 'cmux' fra PATH" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Polecenie powłoki: Odinstaluj „cmux” z PATH" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Команда оболочки: удалить «cmux» из PATH" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Shell naredba: Deinstaliraj 'cmux' iz PATH" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أمر الصدفة: إلغاء تثبيت 'cmux' من PATH" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skallkommando: Avinstaller «cmux» fra PATH" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Comando Shell: Desinstalar 'cmux' do PATH" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำสั่ง Shell: ถอนการติดตั้ง 'cmux' จาก PATH" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kabuk Komutu: 'cmux'u PATH'ten Kaldır" + } + } + } + }, + "command.unpinTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpin Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブのピンを外す" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消固定标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消釘選標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 고정 해제" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab loslösen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desfijar pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désépingler l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sblocca scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Frigør fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odepnij kartę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открепить вкладку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkači tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء تثبيت اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Løsne fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desafixar Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลิกปักหมุดแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme Sabitlemesini Kaldır" + } + } + } + }, + "command.unpinWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpin Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースのピンを外す" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消固定工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消釘選工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 고정 해제" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich loslösen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desfijar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désépingler l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sblocca area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Frigør arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odepnij przestrzeń roboczą" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открепить рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkači radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء تثبيت مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Løsne arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desafixar Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลิกปักหมุดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı Sabitlemesini Kaldır" + } + } + } + }, + "command.vscodeServeWebRestart.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart VS Code Inline Server" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VS Codeインラインサーバーを再起動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启 VS Code 内联服务器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動 VS Code 內嵌伺服器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VS Code 인라인 서버 재시작" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "VS Code Inline-Server neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar servidor en línea de VS Code" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer le serveur VS Code intégré" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia server inline VS Code" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart VS Code Inline Server" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie wbudowany serwer VS Code" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить встроенный сервер VS Code" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokreni VS Code inline server" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تشغيل خادم VS Code المضمّن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start VS Code innebygd server på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar Servidor Inline do VS Code" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ตเซิร์ฟเวอร์ VS Code แบบอินไลน์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "VS Code Satır İçi Sunucusunu Yeniden Başlat" + } + } + } + }, + "command.vscodeServeWebStop.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop VS Code Inline Server" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VS Codeインラインサーバーを停止" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止 VS Code 内联服务器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止 VS Code 內嵌伺服器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VS Code 인라인 서버 중지" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "VS Code Inline-Server stoppen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detener servidor en línea de VS Code" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrêter le serveur VS Code intégré" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Arresta server inline VS Code" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Stop VS Code Inline Server" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zatrzymaj wbudowany serwer VS Code" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остановить встроенный сервер VS Code" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zaustavi VS Code inline server" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف خادم VS Code المضمّن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Stopp VS Code innebygd server" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Parar Servidor Inline do VS Code" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หยุดเซิร์ฟเวอร์ VS Code แบบอินไลน์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "VS Code Satır İçi Sunucusunu Durdur" + } + } + } + }, + "commandPalette.kind.workspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı" + } + } + } + }, + "commandPalette.rename.clearCustomName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(clear custom name)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(カスタム名をクリア)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "(清除自定义名称)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "(清除自訂名稱)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "(사용자 지정 이름 지우기)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "(Benutzerdefinierten Namen löschen)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "(borrar nombre personalizado)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "(effacer le nom personnalisé)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "(cancella nome personalizzato)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "(ryd brugerdefineret navn)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "(wyczyść własną nazwę)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "(очистить пользовательское имя)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "(obriši prilagođeni naziv)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "(مسح الاسم المخصص)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "(fjern egendefinert navn)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "(limpar nome personalizado)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "(ล้างชื่อที่กำหนดเอง)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "(özel adı temizle)" + } + } + } + }, + "commandPalette.rename.tabConfirmHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Press Enter to apply this tab name, or Escape to cancel." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Enterキーでタブ名を適用、Escapeキーでキャンセルします。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按 Enter 应用此标签页名称,或按 Escape 取消。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按 Enter 套用此標籤頁名稱,或按 Escape 取消。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Enter를 눌러 탭 이름을 적용하거나, Escape를 눌러 취소하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Drücken Sie die Eingabetaste, um den Tab-Namen zu übernehmen, oder Escape zum Abbrechen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pulsa Intro para aplicar este nombre de pestaña, o Escape para cancelar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appuyez sur Entrée pour appliquer ce nom d'onglet, ou sur Échap pour annuler." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Premi Invio per applicare il nome della scheda, oppure Esc per annullare." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tryk Enter for at anvende dette fanenavn, eller Escape for at annullere." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Naciśnij Enter, aby zastosować nazwę karty, lub Escape, aby anulować." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите Enter, чтобы применить имя вкладки, или Escape для отмены." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pritisnite Enter za primjenu naziva taba, ili Escape za otkazivanje." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اضغط Enter لتطبيق اسم اللسان، أو Escape للإلغاء." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Trykk Enter for å bruke dette fanenavnet, eller Escape for å avbryte." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pressione Enter para aplicar este nome de aba ou Escape para cancelar." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กด Enter เพื่อใช้ชื่อแท็บนี้ หรือ Escape เพื่อยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu sekme adını uygulamak için Enter tuşuna basın veya iptal etmek için Escape tuşuna basın." + } + } + } + }, + "commandPalette.rename.tabDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a custom tab name." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブのカスタム名を選択してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择自定义标签页名称。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇自訂標籤頁名稱。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 탭 이름을 선택하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie einen benutzerdefinierten Tab-Namen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige un nombre personalizado para la pestaña." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez un nom personnalisé pour l'onglet." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scegli un nome personalizzato per la scheda." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vælg et brugerdefineret fanenavn." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wybierz własną nazwę karty." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите пользовательское имя вкладки." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odaberite prilagođeni naziv taba." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر اسمًا مخصصًا للسان." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg et egendefinert fanenavn." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escolha um nome personalizado para a aba." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลือกชื่อแท็บที่กำหนดเอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel bir sekme adı seçin." + } + } + } + }, + "commandPalette.rename.tabInputHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a tab name. Press Enter to rename, Escape to cancel." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ名を入力してください。Enterで名称変更、Escapeでキャンセルします。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入标签页名称。按 Enter 重命名,按 Escape 取消。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入標籤頁名稱。按 Enter 重新命名,按 Escape 取消。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 이름을 입력하세요. Enter로 이름 변경, Escape로 취소." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie einen Tab-Namen ein. Eingabetaste zum Umbenennen, Escape zum Abbrechen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce un nombre de pestaña. Pulsa Intro para renombrar, Escape para cancelar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez un nom d'onglet. Appuyez sur Entrée pour renommer, Échap pour annuler." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci un nome per la scheda. Premi Invio per rinominare, Esc per annullare." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indtast et fanenavn. Tryk Enter for at omdøbe, Escape for at annullere." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wprowadź nazwę karty. Naciśnij Enter, aby zmienić nazwę, Escape, aby anulować." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите имя вкладки. Нажмите Enter для переименования, Escape для отмены." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Unesite naziv taba. Pritisnite Enter za preimenovanje, Escape za otkazivanje." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل اسم اللسان. اضغط Enter لإعادة التسمية، Escape للإلغاء." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skriv inn et fanenavn. Trykk Enter for å gi nytt navn, Escape for å avbryte." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Insira um nome para a aba. Pressione Enter para renomear, Escape para cancelar." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป้อนชื่อแท็บ กด Enter เพื่อเปลี่ยนชื่อ, Escape เพื่อยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir sekme adı girin. Yeniden adlandırmak için Enter, iptal etmek için Escape tuşuna basın." + } + } + } + }, + "commandPalette.rename.tabPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ名" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁名稱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 이름" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab-Name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre de pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nome scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fanenavn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nazwa karty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя вкладки" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naziv taba" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fanenavn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nome da aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ชื่อแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme adı" + } + } + } + }, + "commandPalette.rename.tabTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブの名称変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 이름 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab umbenennen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę karty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать вкладку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi fanen nytt navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Yeniden Adlandır" + } + } + } + }, + "commandPalette.rename.workspaceConfirmHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Press Enter to apply this workspace name, or Escape to cancel." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Enterキーでワークスペース名を適用、Escapeキーでキャンセルします。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按 Enter 应用此工作区名称,或按 Escape 取消。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按 Enter 套用此工作區名稱,或按 Escape 取消。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Enter를 눌러 작업 공간 이름을 적용하거나, Escape를 눌러 취소하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Drücken Sie die Eingabetaste, um den Arbeitsbereichsnamen zu übernehmen, oder Escape zum Abbrechen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pulsa Intro para aplicar este nombre de espacio de trabajo, o Escape para cancelar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appuyez sur Entrée pour appliquer ce nom d'espace de travail, ou sur Échap pour annuler." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Premi Invio per applicare il nome dell'area di lavoro, oppure Esc per annullare." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tryk Enter for at anvende dette arbejdsområdenavn, eller Escape for at annullere." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Naciśnij Enter, aby zastosować nazwę przestrzeni roboczej, lub Escape, aby anulować." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите Enter, чтобы применить имя рабочего пространства, или Escape для отмены." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pritisnite Enter za primjenu naziva radnog prostora, ili Escape za otkazivanje." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اضغط Enter لتطبيق اسم مساحة العمل، أو Escape للإلغاء." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Trykk Enter for å bruke dette arbeidsområdenavnet, eller Escape for å avbryte." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pressione Enter para aplicar este nome de área de trabalho ou Escape para cancelar." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กด Enter เพื่อใช้ชื่อเวิร์กสเปซนี้ หรือ Escape เพื่อยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu çalışma alanı adını uygulamak için Enter tuşuna basın veya iptal etmek için Escape tuşuna basın." + } + } + } + }, + "commandPalette.rename.workspaceDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a custom workspace name." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースのカスタム名を選択してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择自定义工作区名称。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇自訂工作區名稱。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 작업 공간 이름을 선택하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie einen benutzerdefinierten Arbeitsbereichsnamen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige un nombre personalizado para el espacio de trabajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez un nom personnalisé pour l'espace de travail." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scegli un nome personalizzato per l'area di lavoro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vælg et brugerdefineret arbejdsområdenavn." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wybierz własną nazwę przestrzeni roboczej." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите пользовательское имя рабочего пространства." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odaberite prilagođeni naziv radnog prostora." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر اسمًا مخصصًا لمساحة العمل." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg et egendefinert arbeidsområdenavn." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escolha um nome personalizado para a área de trabalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลือกชื่อเวิร์กสเปซที่กำหนดเอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel bir çalışma alanı adı seçin." + } + } + } + }, + "commandPalette.rename.workspaceInputHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a workspace name. Press Enter to rename, Escape to cancel." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名を入力してください。Enterで名称変更、Escapeでキャンセルします。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入工作区名称。按 Enter 重命名,按 Escape 取消。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入工作區名稱。按 Enter 重新命名,按 Escape 取消。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름을 입력하세요. Enter로 이름 변경, Escape로 취소." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie einen Arbeitsbereichsnamen ein. Eingabetaste zum Umbenennen, Escape zum Abbrechen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce un nombre de espacio de trabajo. Pulsa Intro para renombrar, Escape para cancelar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez un nom d'espace de travail. Appuyez sur Entrée pour renommer, Échap pour annuler." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci un nome per l'area di lavoro. Premi Invio per rinominare, Esc per annullare." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indtast et arbejdsområdenavn. Tryk Enter for at omdøbe, Escape for at annullere." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wprowadź nazwę przestrzeni roboczej. Naciśnij Enter, aby zmienić nazwę, Escape, aby anulować." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите имя рабочего пространства. Нажмите Enter для переименования, Escape для отмены." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Unesite naziv radnog prostora. Pritisnite Enter za preimenovanje, Escape za otkazivanje." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل اسم مساحة العمل. اضغط Enter لإعادة التسمية، Escape للإلغاء." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skriv inn et arbeidsområdenavn. Trykk Enter for å gi nytt navn, Escape for å avbryte." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Insira um nome para a área de trabalho. Pressione Enter para renomear, Escape para cancelar." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป้อนชื่อเวิร์กสเปซ กด Enter เพื่อเปลี่ยนชื่อ, Escape เพื่อยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir çalışma alanı adı girin. Yeniden adlandırmak için Enter, iptal etmek için Escape tuşuna basın." + } + } + } + }, + "commandPalette.rename.workspacePlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區名稱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Name des Arbeitsbereichs" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre del espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nome area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområdenavn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nazwa przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naziv radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområdenavn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nome da área de trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ชื่อเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı adı" + } + } + } + }, + "commandPalette.rename.workspaceTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの名称変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich umbenennen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi arbeidsområdet nytt navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Yeniden Adlandır" + } + } + } + }, + "commandPalette.search.commandsEmpty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No commands match your search." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索に一致するコマンドがありません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有匹配的命令。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有符合搜尋的指令。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색과 일치하는 명령어가 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Befehle entsprechen Ihrer Suche." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ningún comando coincide con tu búsqueda." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune commande ne correspond à votre recherche." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun comando corrisponde alla ricerca." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen kommandoer matcher din søgning." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak poleceń pasujących do wyszukiwania." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет команд, соответствующих запросу." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nijedna naredba ne odgovara vašoj pretrazi." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد أوامر تطابق بحثك." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen kommandoer samsvarer med søket ditt." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhum comando corresponde à sua pesquisa." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีคำสั่งที่ตรงกับการค้นหาของคุณ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aramanızla eşleşen komut yok." + } + } + } + }, + "commandPalette.search.commandsPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type a command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドを入力" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入指令" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "명령어를 입력하세요" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Befehl eingeben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escribe un comando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez une commande" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Digita un comando" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skriv en kommando" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wpisz polecenie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите команду" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Unesite naredbu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اكتب أمرًا" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skriv en kommando" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Digite um comando" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พิมพ์คำสั่ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir komut yazın" + } + } + } + }, + "commandPalette.search.switcherEmpty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No workspaces match your search." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索に一致するワークスペースがありません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有匹配的工作区。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有符合搜尋的工作區。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색과 일치하는 작업 공간이 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Arbeitsbereiche entsprechen Ihrer Suche." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ningún espacio de trabajo coincide con tu búsqueda." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun espace de travail ne correspond à votre recherche." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna area di lavoro corrisponde alla ricerca." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen arbejdsområder matcher din søgning." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak przestrzeni roboczych pasujących do wyszukiwania." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет рабочих пространств, соответствующих запросу." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nijedan radni prostor ne odgovara vašoj pretrazi." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد مساحات عمل تطابق بحثك." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen arbeidsområder samsvarer med søket ditt." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma área de trabalho corresponde à sua pesquisa." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีเวิร์กสเปซที่ตรงกับการค้นหาของคุณ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aramanızla eşleşen çalışma alanı yok." + } + } + } + }, + "commandPalette.search.switcherPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 검색" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche durchsuchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher des espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cerca aree di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg i arbejdsområder" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szukaj przestrzeni roboczych" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск рабочих пространств" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pretraži radne prostore" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث في مساحات العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Søk i arbeidsområder" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar áreas de trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหาเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanlarını ara" + } + } + } + }, + "commandPalette.subtitle.browserWithName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser • %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ • %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器 • %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器 • %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 • %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser • %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Navegador • %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigateur • %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Browser • %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browser • %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przeglądarka • %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браузер • %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preglednik • %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المتصفح • %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleser • %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Navegador • %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เบราว์เซอร์ • %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı • %@" + } + } + } + }, + "commandPalette.subtitle.tabFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вкладка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme" + } + } + } + }, + "commandPalette.subtitle.tabWithName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab • %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ • %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页 • %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁 • %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 • %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab • %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pestaña • %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet • %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda • %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fane • %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karta • %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вкладка • %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tab • %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان • %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fane • %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aba • %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บ • %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme • %@" + } + } + } + }, + "commandPalette.subtitle.terminalWithName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル • %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端 • %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機 • %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 • %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Terminale • %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Терминал • %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الطرفية • %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เทอร์มินัล • %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal • %@" + } + } + } + }, + "commandPalette.subtitle.workspaceFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı" + } + } + } + }, + "commandPalette.subtitle.workspaceWithName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace • %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース • %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区 • %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區 • %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 • %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich • %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo • %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail • %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro • %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde • %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza • %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство • %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor • %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل • %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde • %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho • %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ • %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı • %@" + } + } + } + }, + "commandPalette.switcher.windowLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ %lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口 %lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗 %lld" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 %lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster %lld" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana %lld" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre %lld" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra %lld" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vindue %lld" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Okno %lld" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно %lld" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozor %lld" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النافذة %lld" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindu %lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela %lld" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่าง %lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere %lld" + } + } + } + }, + "commandPalette.switcher.workspaceLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı" + } + } + } + }, + "common.allow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "許可" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "허용" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erlauben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autoriser" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Consenti" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tillad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zezwól" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dozvoli" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سماح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tillat" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Permitir" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อนุญาต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İzin Ver" + } + } + } + }, + "common.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Annulla" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Annuller" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Anuluj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отменить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkaži" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avbryt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Vazgeç" + } + } + } + }, + "common.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapat" + } + } + } + }, + "common.copyDetails": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細をコピー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拷贝详细信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拷貝詳細資訊" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부 정보 복사" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Details kopieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar detalles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier les détails" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Copia dettagli" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kopier detaljer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kopiuj szczegóły" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопировать подробности" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kopiraj detalje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ التفاصيل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kopier detaljer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar Detalhes" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คัดลอกรายละเอียด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayrıntıları Kopyala" + } + } + } + }, + "common.dontSave": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Don't Save" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存しない" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不存储" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不儲存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장 안 함" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht sichern" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No guardar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ne pas enregistrer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non salvare" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gem ikke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie zachowuj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не сохранять" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ne spremi" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عدم الحفظ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ikke arkiver" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não Salvar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่บันทึก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaydetme" + } + } + } + }, + "common.installAndRelaunch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install and Relaunch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストールして再起動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装并重新启动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝並重新啟動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치 후 재실행" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Installieren und neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalar y reiniciar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installer et relancer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Installa e riavvia" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Installer og genstart" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zainstaluj i uruchom ponownie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установить и перезапустить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Instaliraj i ponovo pokreni" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت وإعادة التشغيل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Installer og start på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Instalar e Reiniciar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ติดตั้งและเปิดใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yükle ve Yeniden Başlat" + } + } + } + }, + "common.later": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Later" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "後で" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "稍后" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "稍後" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나중에" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Später" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Más tarde" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Plus tard" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Più tardi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Senere" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Później" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Позже" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kasnije" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لاحقًا" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Senere" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Depois" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ภายหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Daha Sonra" + } + } + } + }, + "common.notNow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not Now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今はしない" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以后再说" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "現在不要" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나중에" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht jetzt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ahora no" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas maintenant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non ora" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ikke nu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie teraz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не сейчас" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ne sada" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ليس الآن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ikke nå" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Agora Não" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ใช่ตอนนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şimdi Değil" + } + } + } + }, + "common.ok": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tamam" + } + } + } + }, + "common.rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前を変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Umbenennen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi nytt navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden Adlandır" + } + } + } + }, + "common.restartLater": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart Later" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "後で再起動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "稍后重启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "稍後重新啟動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나중에 재시작" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Später neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar más tarde" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer plus tard" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia più tardi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart senere" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie później" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить позже" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokreni kasnije" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التشغيل لاحقًا" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start på nytt senere" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar Depois" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ตภายหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Daha Sonra Yeniden Başlat" + } + } + } + }, + "common.restartNow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart Now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今すぐ再起動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即重启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即重新啟動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 재시작" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar ahora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer maintenant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia ora" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart nu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie teraz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить сейчас" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokreni sada" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التشغيل الآن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start på nytt nå" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar Agora" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ตเดี๋ยวนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şimdi Yeniden Başlat" + } + } + } + }, + "common.retry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再試行" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重試" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시도" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut versuchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reintentar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réessayer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riprova" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Prøv igen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ponów" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokušaj ponovo" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة المحاولة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Prøv igjen" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tentar Novamente" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลองใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tekrar Dene" + } + } + } + }, + "common.skip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳过" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "略過" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건너뛰기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überspringen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Omitir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ignorer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Salta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Spring over" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pomiń" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пропустить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preskoči" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hopp over" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pular" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้าม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Atla" + } + } + } + }, + "contextMenu.chooseCustomColor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose Custom Color…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムカラーを選択…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选取自定义颜色..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇自訂顏色..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 색상 선택…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eigene Farbe wählen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir color personalizado…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir une couleur personnalisée..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scegli colore personalizzato…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vælg brugerdefineret farve…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wybierz własny kolor…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать пользовательский цвет..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odaberi prilagođenu boju…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختيار لون مخصص…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg egendefinert farge …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escolher Cor Personalizada…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลือกสีที่กำหนดเอง..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel Renk Seç…" + } + } + } + }, + "contextMenu.clearColor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Color" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カラーをクリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除顏色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "색상 지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar color" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer la couleur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi colore" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd farve" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść kolor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Убрать цвет" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši boju" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح اللون" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern farge" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Cor" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างสี" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Rengi Temizle" + } + } + } + }, + "contextMenu.closeOtherWorkspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Other Workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "他のワークスペースを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭其他工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉其他工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다른 작업 공간 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Andere Arbeitsbereiche schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar otros espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer les autres espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi altre aree di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk andre arbejdsområder" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij inne przestrzenie robocze" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть другие рабочие пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori ostale radne prostore" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحات العمل الأخرى" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk andre arbeidsområder" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Outras Áreas de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซอื่น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Diğer Çalışma Alanlarını Kapat" + } + } + } + }, + "contextMenu.closeWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij przestrzeń roboczą" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Kapat" + } + } + } + }, + "contextMenu.closeWorkspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer les espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi aree di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk arbejdsområder" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij przestrzenie robocze" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть рабочие пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori radne prostore" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحات العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk arbeidsområder" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Áreas de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanlarını Kapat" + } + } + } + }, + "contextMenu.closeWorkspacesAbove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Workspaces Above" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上のワークスペースを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭上方工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉上方的工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위쪽 작업 공간 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche darüber schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar espacios de trabajo superiores" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer les espaces de travail au-dessus" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi aree di lavoro sopra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk arbejdsområder ovenfor" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij przestrzenie robocze powyżej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть рабочие пространства выше" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori radne prostore iznad" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحات العمل أعلاه" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk arbeidsområder over" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Áreas de Trabalho Acima" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซด้านบน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yukarıdaki Çalışma Alanlarını Kapat" + } + } + } + }, + "contextMenu.closeWorkspacesBelow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Workspaces Below" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下のワークスペースを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭下方工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉下方的工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래쪽 작업 공간 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche darunter schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar espacios de trabajo inferiores" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer les espaces de travail en dessous" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi aree di lavoro sotto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk arbejdsområder nedenfor" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij przestrzenie robocze poniżej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть рабочие пространства ниже" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori radne prostore ispod" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحات العمل أدناه" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk arbeidsområder under" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Áreas de Trabalho Abaixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซด้านล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aşağıdaki Çalışma Alanlarını Kapat" + } + } + } + }, + "contextMenu.markWorkspaceRead": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark Workspace as Read" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを既読にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将工作区标记为已读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將工作區標為已讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간을 읽음으로 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich als gelesen markieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Marcar espacio de trabajo como leído" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Marquer l'espace de travail comme lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segna area di lavoro come letta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Marker arbejdsområde som læst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oznacz przestrzeń roboczą jako przeczytaną" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить рабочее пространство как прочитанное" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi radni prostor kao pročitan" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليم مساحة العمل كمقروءة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Merk arbeidsområde som lest" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Marcar Área de Trabalho como Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำเครื่องหมายเวิร์กสเปซว่าอ่านแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Okundu Olarak İşaretle" + } + } + } + }, + "contextMenu.markWorkspaceUnread": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark Workspace as Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを未読にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将工作区标记为未读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將工作區標為未讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간을 읽지 않음으로 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich als ungelesen markieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Marcar espacio de trabajo como no leído" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Marquer l'espace de travail comme non lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segna area di lavoro come non letta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Marker arbejdsområde som ulæst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oznacz przestrzeń roboczą jako nieprzeczytaną" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить рабочее пространство как непрочитанное" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi radni prostor kao nepročitan" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليم مساحة العمل كغير مقروءة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Merk arbeidsområde som ulest" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Marcar Área de Trabalho como Não Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำเครื่องหมายเวิร์กสเปซว่ายังไม่อ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Okunmadı Olarak İşaretle" + } + } + } + }, + "contextMenu.markWorkspacesRead": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark Workspaces as Read" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを既読にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将工作区标记为已读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將工作區標為已讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간을 읽음으로 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche als gelesen markieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Marcar espacios de trabajo como leídos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Marquer les espaces de travail comme lus" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segna aree di lavoro come lette" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Marker arbejdsområder som læste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oznacz przestrzenie robocze jako przeczytane" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить рабочие пространства как прочитанные" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi radne prostore kao pročitane" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليم مساحات العمل كمقروءة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Merk arbeidsområder som lest" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Marcar Áreas de Trabalho como Lidas" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำเครื่องหมายเวิร์กสเปซว่าอ่านแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanlarını Okundu Olarak İşaretle" + } + } + } + }, + "contextMenu.markWorkspacesUnread": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark Workspaces as Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを未読にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将工作区标记为未读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將工作區標為未讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간을 읽지 않음으로 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche als ungelesen markieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Marcar espacios de trabajo como no leídos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Marquer les espaces de travail comme non lus" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segna aree di lavoro come non lette" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Marker arbejdsområder som ulæste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oznacz przestrzenie robocze jako nieprzeczytane" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить рабочие пространства как непрочитанные" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi radne prostore kao nepročitane" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليم مساحات العمل كغير مقروءة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Merk arbeidsområder som ulest" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Marcar Áreas de Trabalho como Não Lidas" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำเครื่องหมายเวิร์กสเปซว่ายังไม่อ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanlarını Okunmadı Olarak İşaretle" + } + } + } + }, + "contextMenu.moveDown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下移" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下移" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach unten bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt ned" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pomjeri dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลื่อนลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aşağı Taşı" + } + } + } + }, + "contextMenu.moveToTop": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Top" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一番上に移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移到顶部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移至最上方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "맨 위로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach oben bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover al inicio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer en haut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta in cima" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt til toppen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś na górę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить наверх" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pomjeri na vrh" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل إلى الأعلى" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt til toppen" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover para o Topo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้ายไปด้านบน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "En Üste Taşı" + } + } + } + }, + "contextMenu.moveUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上に移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上移" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上移" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach oben bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover hacia arriba" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer vers le haut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta in alto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt op" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś w górę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить вверх" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pomjeri gore" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل للأعلى" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt opp" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover para Cima" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลื่อนขึ้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yukarı Taşı" + } + } + } + }, + "contextMenu.moveWorkspaceToWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Workspace to Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースをウインドウに移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将工作区移到窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將工作區移至視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간을 윈도우로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich in Fenster bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover espacio de trabajo a ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer l'espace de travail vers une fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta area di lavoro in una finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt arbejdsområde til vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś przestrzeń roboczą do okna" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить рабочее пространство в окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Premjesti radni prostor u prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل مساحة العمل إلى نافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt arbeidsområde til vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover Área de Trabalho para Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้ายเวิร์กสเปซไปยังหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Pencereye Taşı" + } + } + } + }, + "contextMenu.moveWorkspacesToWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Workspaces to Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースをウインドウに移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将工作区移到窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將工作區移至視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간을 윈도우로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche in Fenster bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover espacios de trabajo a ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer les espaces de travail vers une fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta aree di lavoro in una finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt arbejdsområder til vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś przestrzenie robocze do okna" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить рабочие пространства в окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Premjesti radne prostore u prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل مساحات العمل إلى نافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt arbeidsområder til vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover Áreas de Trabalho para Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้ายเวิร์กสเปซไปยังหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanlarını Pencereye Taşı" + } + } + } + }, + "contextMenu.newWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neues Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowe okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่างใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Pencere" + } + } + } + }, + "contextMenu.pinWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pin Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースをピンで固定" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "固定工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "釘選工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 고정" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich anheften" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fijar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épingler l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fissa area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fastgør arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przypnij przestrzeń roboczą" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепить рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zakači radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fest arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fixar Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปักหมุดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Sabitle" + } + } + } + }, + "contextMenu.pinWorkspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pin Workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースをピンで固定" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "固定工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "釘選工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 고정" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche anheften" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fijar espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épingler les espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fissa aree di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fastgør arbejdsområder" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przypnij przestrzenie robocze" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепить рабочие пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zakači radne prostore" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت مساحات العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fest arbeidsområder" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fixar Áreas de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปักหมุดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanlarını Sabitle" + } + } + } + }, + "contextMenu.removeCustomWorkspaceName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove Custom Workspace Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムワークスペース名を削除" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除自定义工作区名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除自訂工作區名稱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 작업 공간 이름 제거" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierten Arbeitsbereichsnamen entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar nombre personalizado del espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer le nom personnalisé de l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi nome personalizzato area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern brugerdefineret arbejdsområdenavn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń własną nazwę przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить пользовательское имя рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloni prilagođeni naziv radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة اسم مساحة العمل المخصص" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern egendefinert arbeidsområdenavn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover Nome Personalizado da Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบชื่อเวิร์กสเปซที่กำหนดเอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel Çalışma Alanı Adını Kaldır" + } + } + } + }, + "contextMenu.renameWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Workspace…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの名称変更…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名工作区..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名工作區..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름 변경…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich umbenennen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar espacio de trabajo…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'espace de travail..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina area di lavoro…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb arbejdsområde…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę przestrzeni roboczej…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать рабочее пространство..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj radni prostor…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية مساحة العمل…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi arbeidsområdet nytt navn …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Área de Trabalho…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อเวิร์กสเปซ..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Yeniden Adlandır…" + } + } + } + }, + "contextMenu.unpinWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpin Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースのピンを外す" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消固定工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消釘選工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 고정 해제" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich loslösen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desfijar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désépingler l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sblocca area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Frigør arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odepnij przestrzeń roboczą" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открепить рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkači radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء تثبيت مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Løsne arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desafixar Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลิกปักหมุดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı Sabitlemesini Kaldır" + } + } + } + }, + "contextMenu.unpinWorkspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpin Workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースのピンを外す" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消固定工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消釘選工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 고정 해제" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche loslösen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desfijar espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désépingler les espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sblocca aree di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Frigør arbejdsområder" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odepnij przestrzenie robocze" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открепить рабочие пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkači radne prostore" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء تثبيت مساحات العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Løsne arbeidsområder" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desafixar Áreas de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลิกปักหมุดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanları Sabitlemesini Kaldır" + } + } + } + }, + "contextMenu.workspaceColor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace Color" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースカラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區顏色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 색상" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereichsfarbe" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color del espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur de l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområdefarve" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolor przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boja radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområdefarge" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor da Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı Rengi" + } + } + } + }, + "dialog.closeLastTabWindow.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close the last tab and close the window." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最後のタブを閉じ、ウインドウを閉じます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将关闭最后一个标签页并关闭窗口。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將關閉最後一個標籤頁並關閉視窗。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마지막 탭이 닫히면 윈도우도 닫힙니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dadurch wird der letzte Tab geschlossen und das Fenster geschlossen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto cerrará la última pestaña y cerrará la ventana." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela fermera le dernier onglet et fermera la fenêtre." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa operazione chiuderà l'ultima scheda e la finestra." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dette lukker den sidste fane og lukker vinduet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spowoduje to zamknięcie ostatniej karty i zamknięcie okna." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это закроет последнюю вкладку и окно." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo će zatvoriti posljednji tab i zatvoriti prozor." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيؤدي هذا إلى إغلاق آخر لسان وإغلاق النافذة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette vil lukke den siste fanen og lukke vinduet." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isto fechará a última aba e fechará a janela." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การดำเนินการนี้จะปิดแท็บสุดท้ายและปิดหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu, son sekmeyi kapatacak ve pencereyi kapatacak." + } + } + } + }, + "dialog.closeLastTabWorkspace.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close the last tab and close its workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最後のタブを閉じ、ワークスペースを閉じます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将关闭最后一个标签页并关闭其工作区。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將關閉最後一個標籤頁並關閉其工作區。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마지막 탭이 닫히면 해당 작업 공간도 닫힙니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dadurch wird der letzte Tab geschlossen und der Arbeitsbereich geschlossen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto cerrará la última pestaña y cerrará su espacio de trabajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela fermera le dernier onglet et fermera son espace de travail." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa operazione chiuderà l'ultima scheda e la sua area di lavoro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dette lukker den sidste fane og lukker dets arbejdsområde." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spowoduje to zamknięcie ostatniej karty i zamknięcie jej przestrzeni roboczej." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это закроет последнюю вкладку и её рабочее пространство." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo će zatvoriti posljednji tab i zatvoriti njegov radni prostor." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيؤدي هذا إلى إغلاق آخر لسان وإغلاق مساحة العمل." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette vil lukke den siste fanen og lukke arbeidsområdet." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isto fechará a última aba e fechará sua área de trabalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การดำเนินการนี้จะปิดแท็บสุดท้ายและปิดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu, son sekmeyi kapatacak ve çalışma alanını kapatacak." + } + } + } + }, + "dialog.closeOtherTabs.message.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close 1 tab in this pane:\n%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このペインの 1 個のタブを閉じます:\n%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将关闭此面板中的 1 个标签页:\n%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將關閉此面板中的 1 個標籤頁:\n%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 패널에서 탭 1개를 닫습니다:\n%@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dadurch wird 1 Tab in diesem Bereich geschlossen:\n%@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto cerrará 1 pestaña en este panel:\n%@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela fermera 1 onglet dans ce panneau :\n%@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa operazione chiuderà 1 scheda in questo pannello:\n%@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dette lukker 1 fane i dette panel:\n%@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spowoduje to zamknięcie 1 karty w tym panelu:\n%@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Будет закрыта 1 вкладка в этой панели:\n%@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo će zatvoriti 1 tab u ovom panelu:\n%@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيؤدي هذا إلى إغلاق لسان واحد في هذه اللوحة:\n%@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette vil lukke 1 fane i dette panelet:\n%@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isto fechará 1 aba neste painel:\n%@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การดำเนินการนี้จะปิด 1 แท็บในบานหน้าต่างนี้:\n%@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu bölmedeki 1 sekme kapatılacak:\n%@" + } + } + } + }, + "dialog.closeOtherTabs.message.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close %1$lld tabs in this pane:\n%2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このペインの %1$lld 個のタブを閉じます:\n%2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将关闭此面板中的 %1$lld 个标签页:\n%2$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將關閉此面板中的 %1$lld 個標籤頁:\n%2$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 패널에서 탭 %1$lld개를 닫습니다:\n%2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dadurch werden %1$lld Tabs in diesem Bereich geschlossen:\n%2$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto cerrará %1$lld pestañas en este panel:\n%2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela fermera %1$lld onglets dans ce panneau :\n%2$@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa operazione chiuderà %1$lld schede in questo pannello:\n%2$@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dette lukker %1$lld faner i dette panel:\n%2$@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spowoduje to zamknięcie %1$lld kart w tym panelu:\n%2$@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Будет закрыто вкладок в этой панели: %1$lld\n%2$@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo će zatvoriti %1$lld tabova u ovom panelu:\n%2$@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيؤدي هذا إلى إغلاق %1$lld لسان في هذه اللوحة:\n%2$@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette vil lukke %1$lld faner i dette panelet:\n%2$@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isto fechará %1$lld abas neste painel:\n%2$@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การดำเนินการนี้จะปิด %1$lld แท็บในบานหน้าต่างนี้:\n%2$@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu bölmedeki %1$lld sekme kapatılacak:\n%2$@" + } + } + } + }, + "dialog.closeOtherTabs.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close other tabs?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "他のタブを閉じますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭其他标签页?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要關閉其他標籤頁嗎?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다른 탭을 닫으시겠습니까?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Andere Tabs schließen?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Cerrar otras pestañas?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer les autres onglets ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudere le altre schede?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk andre faner?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknąć inne karty?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть другие вкладки?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvoriti ostale tabove?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق الألسنة الأخرى؟" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukke andre faner?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar outras abas?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดแท็บอื่นหรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Diğer sekmeler kapatılsın mı?" + } + } + } + }, + "dialog.closeTab.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapat" + } + } + } + }, + "dialog.closeTab.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close the current tab." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のタブを閉じます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将关闭当前标签页。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將關閉目前的標籤頁。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 탭을 닫습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dadurch wird der aktuelle Tab geschlossen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto cerrará la pestaña actual." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela fermera l'onglet actuel." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa operazione chiuderà la scheda corrente." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dette lukker den aktuelle fane." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spowoduje to zamknięcie bieżącej karty." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текущая вкладка будет закрыта." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo će zatvoriti trenutni tab." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيؤدي هذا إلى إغلاق اللسان الحالي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette vil lukke den gjeldende fanen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isto fechará a aba atual." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การดำเนินการนี้จะปิดแท็บปัจจุบัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu, geçerli sekmeyi kapatacak." + } + } + } + }, + "dialog.closeTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close tab?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを閉じますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭标签页?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要關閉標籤頁嗎?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭을 닫으시겠습니까?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab schließen?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Cerrar pestaña?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l'onglet ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudere la scheda?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk fane?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknąć kartę?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть вкладку?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvoriti tab?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق اللسان؟" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukke fane?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar aba?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดแท็บหรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme kapatılsın mı?" + } + } + } + }, + "dialog.closeWindow.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close the current window and all of its workspaces." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のウィンドウとそのすべてのワークスペースを閉じます。" + } + } + } + }, + "dialog.closeWindow.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close window?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウィンドウを閉じますか?" + } + } + } + }, + "dialog.closeWorkspace.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close the workspace and all of its panels." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースとそのすべてのパネルを閉じます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将关闭工作区及其所有面板。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將關閉工作區及其所有面板。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간과 모든 패널을 닫습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dadurch werden der Arbeitsbereich und alle zugehörigen Bereiche geschlossen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto cerrará el espacio de trabajo y todos sus paneles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela fermera l'espace de travail et tous ses panneaux." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa operazione chiuderà l'area di lavoro e tutti i suoi pannelli." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dette lukker arbejdsområdet og alle dets paneler." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spowoduje to zamknięcie przestrzeni roboczej i wszystkich jej paneli." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство и все его панели будут закрыты." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo će zatvoriti radni prostor i sve njegove panele." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيؤدي هذا إلى إغلاق مساحة العمل وجميع لوحاتها." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette vil lukke arbeidsområdet og alle panelene." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isto fechará a área de trabalho e todos os seus painéis." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การดำเนินการนี้จะปิดเวิร์กสเปซและแผงทั้งหมด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu, çalışma alanını ve tüm panellerini kapatacak." + } + } + } + }, + "dialog.closeWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close workspace?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭工作区?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要關閉工作區嗎?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간을 닫으시겠습니까?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich schließen?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Cerrar espacio de trabajo?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l'espace de travail ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudere l'area di lavoro?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk arbejdsområde?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknąć przestrzeń roboczą?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть рабочее пространство?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvoriti radni prostor?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحة العمل؟" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukke arbeidsområde?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar área de trabalho?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซหรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı kapatılsın mı?" + } + } + } + }, + "dialog.dontWarnCmdQ": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Don't warn again for Cmd+Q" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q の警告を表示しない" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不再提示 Cmd+Q 警告" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不再顯示 Cmd+Q 警告" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q에 대해 다시 경고하지 않기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht erneut bei Cmd+Q warnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No volver a advertir para Cmd+Q" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ne plus avertir pour Cmd+Q" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non avvisare più per Cmd+Q" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Advar ikke igen for Cmd+Q" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie ostrzegaj ponownie przy Cmd+Q" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Больше не предупреждать при Cmd+Q" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ne upozoravaj ponovo za Cmd+Q" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عدم التحذير مجددًا عند Cmd+Q" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ikke advar igjen for Cmd+Q" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não avisar novamente para Cmd+Q" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ต้องเตือนอีกสำหรับ Cmd+Q" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q için bir daha uyarma" + } + } + } + }, + "dialog.enableNotifications.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications are disabled for cmux. Enable them in System Settings to see alerts." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxの通知が無効になっています。アラートを表示するにはシステム設定で有効にしてください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 的通知已被禁用。请在系统设置中启用通知以接收提醒。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 的通知已停用。請在「系統設定」中啟用,以接收提示。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux에 대한 알림이 비활성화되어 있습니다. 알림을 보려면 시스템 설정에서 활성화하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen sind für cmux deaktiviert. Aktivieren Sie sie in den Systemeinstellungen, um Hinweise zu sehen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las notificaciones están desactivadas para cmux. Actívalas en Ajustes del Sistema para ver alertas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les notifications sont désactivées pour cmux. Activez-les dans les Réglages Système pour voir les alertes." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le notifiche sono disabilitate per cmux. Abilitale nelle Impostazioni di Sistema per visualizzare gli avvisi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Notifikationer er deaktiveret for cmux. Aktiver dem i Systemindstillinger for at se advarsler." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia są wyłączone dla cmux. Włącz je w Ustawieniach systemowych, aby widzieć alerty." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления для cmux отключены. Включите их в Системных настройках, чтобы видеть оповещения." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja su onemogućena za cmux. Omogućite ih u Postavkama sistema da biste vidjeli upozorenja." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإشعارات معطلة لـ cmux. قم بتفعيلها في إعدادات النظام لرؤية التنبيهات." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Varsler er deaktivert for cmux. Aktiver dem i Systeminnstillinger for å se varsler." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As notificações estão desativadas para o cmux. Ative-as nos Ajustes do Sistema para ver os alertas." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือนถูกปิดใช้งานสำหรับ cmux เปิดใช้งานในการตั้งค่าระบบเพื่อดูการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux için bildirimler devre dışı. Uyarıları görmek için Sistem Ayarları'nda etkinleştirin." + } + } + } + }, + "dialog.enableNotifications.notNow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not Now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今はしない" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以后再说" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "現在不要" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나중에" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht jetzt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ahora no" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas maintenant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non ora" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ikke nu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie teraz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не сейчас" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ne sada" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ليس الآن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ikke nå" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Agora Não" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ใช่ตอนนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şimdi Değil" + } + } + } + }, + "dialog.enableNotifications.openSettings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir Ajustes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les réglages" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri impostazioni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn indstillinger" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz ustawienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть настройки" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori postavke" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الإعدادات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne innstillinger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Ajustes" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดการตั้งค่า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarları Aç" + } + } + } + }, + "dialog.enableNotifications.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Notifications for cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxの通知を有効にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用 cmux 通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為 cmux 啟用通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 알림 활성화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen für cmux aktivieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar notificaciones para cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer les notifications pour cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Abilita notifiche per cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Aktiver notifikationer for cmux" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Włącz powiadomienia dla cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить уведомления для cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Omogući obavještenja za cmux" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفعيل الإشعارات لـ cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktiver varsler for cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ativar Notificações para o cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดใช้งานการแจ้งเตือนสำหรับ cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux İçin Bildirimleri Etkinleştir" + } + } + } + }, + "dialog.moveFailed.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not move this tab to the selected destination." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxはこのタブを選択した移動先に移動できませんでした。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法将此标签页移动到所选目标。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法將此標籤頁移至所選的目的地。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux에서 이 탭을 선택한 대상으로 이동할 수 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte diesen Tab nicht zum ausgewählten Ziel verschieben." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo mover esta pestaña al destino seleccionado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n'a pas pu déplacer cet onglet vers la destination sélectionnée." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non è riuscito a spostare questa scheda nella destinazione selezionata." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke flytte denne fane til den valgte destination." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie mógł przenieść tej karty do wybranego miejsca docelowego." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не удалось переместить эту вкладку в выбранное место назначения." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux nije mogao premjestiti ovaj tab na odabrano odredište." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتمكن cmux من نقل هذا اللسان إلى الوجهة المحددة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke flytte denne fanen til den valgte destinasjonen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não conseguiu mover esta aba para o destino selecionado." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ไม่สามารถย้ายแท็บนี้ไปยังปลายทางที่เลือกได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux bu sekmeyi seçilen hedefe taşıyamadı." + } + } + } + }, + "dialog.moveFailed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "移動失敗" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移動失敗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이동 실패" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verschieben fehlgeschlagen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al mover" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec du déplacement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Spostamento non riuscito" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flytning mislykkedes" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenoszenie nie powiodło się" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка перемещения" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Premještanje nije uspjelo" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل النقل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytting mislyktes" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao Mover" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การย้ายล้มเหลว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Taşıma Başarısız" + } + } + } + }, + "dialog.moveTab.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a destination for this tab." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このタブの移動先を選択してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请为此标签页选择目标位置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇此標籤頁的目的地。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 탭의 대상을 선택하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie ein Ziel für diesen Tab." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige un destino para esta pestaña." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez une destination pour cet onglet." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scegli una destinazione per questa scheda." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vælg en destination for denne fane." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wybierz miejsce docelowe dla tej karty." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите место назначения для этой вкладки." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odaberite odredište za ovaj tab." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر وجهة لهذا اللسان." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg en destinasjon for denne fanen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escolha um destino para esta aba." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลือกปลายทางสำหรับแท็บนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu sekme için bir hedef seçin." + } + } + } + }, + "dialog.moveTab.move": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verschieben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Premjesti" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้าย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Taşı" + } + } + } + }, + "dialog.moveTab.newWorkspaceCurrentWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace in Current Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のウインドウの新規ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前窗口的新工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前視窗的新工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 윈도우의 새 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Arbeitsbereich im aktuellen Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo espacio de trabajo en la ventana actual" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel espace de travail dans la fenêtre actuelle" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova area di lavoro nella finestra corrente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt arbejdsområde i nuværende vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa przestrzeń robocza w bieżącym oknie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое рабочее пространство в текущем окне" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi radni prostor u trenutnom prozoru" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل جديدة في النافذة الحالية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt arbeidsområde i gjeldende vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Área de Trabalho na Janela Atual" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซใหม่ในหน้าต่างปัจจุบัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Pencerede Yeni Çalışma Alanı" + } + } + } + }, + "dialog.moveTab.selectedWorkspaceNewWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected Workspace in New Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したワークスペースを新規ウインドウに" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新窗口中的所选工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所選工作區至新視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 작업 공간을 새 윈도우로" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgewählter Arbeitsbereich in neuem Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo seleccionado en nueva ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail sélectionné dans une nouvelle fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro selezionata in una nuova finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Valgt arbejdsområde i nyt vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wybrana przestrzeń robocza w nowym oknie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбранное рабочее пространство в новом окне" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odabrani radni prostor u novom prozoru" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل المحددة في نافذة جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Valgt arbeidsområde i nytt vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho Selecionada em Nova Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซที่เลือกในหน้าต่างใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Pencerede Seçili Çalışma Alanı" + } + } + } + }, + "dialog.moveTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブの移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移動標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab verschieben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś kartę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить вкладку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Premjesti tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้ายแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Taşı" + } + } + } + }, + "dialog.quitCmux.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close all windows and workspaces." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのウインドウとワークスペースを閉じます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将关闭所有窗口和工作区。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將關閉所有視窗和工作區。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 윈도우와 작업 공간을 닫습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dadurch werden alle Fenster und Arbeitsbereiche geschlossen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto cerrará todas las ventanas y espacios de trabajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela fermera toutes les fenêtres et tous les espaces de travail." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa operazione chiuderà tutte le finestre e le aree di lavoro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dette lukker alle vinduer og arbejdsområder." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spowoduje to zamknięcie wszystkich okien i przestrzeni roboczych." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все окна и рабочие пространства будут закрыты." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo će zatvoriti sve prozore i radne prostore." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيؤدي هذا إلى إغلاق جميع النوافذ ومساحات العمل." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette vil lukke alle vinduer og arbeidsområder." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isto fechará todas as janelas e áreas de trabalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การดำเนินการนี้จะปิดหน้าต่างและเวิร์กสเปซทั้งหมด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu, tüm pencereleri ve çalışma alanlarını kapatacak." + } + } + } + }, + "dialog.quitCmux.quit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結束" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Salir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quitter" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Esci" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Afslut" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zakończ" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Завершить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إنهاء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avslutt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Encerrar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ออก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çık" + } + } + } + }, + "dialog.quitCmux.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit cmux?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux を終了しますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出 cmux?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要結束 cmux 嗎?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux를 종료하시겠습니까?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux beenden?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Salir de cmux?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quitter cmux ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Uscire da cmux?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Afslut cmux?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zakończyć cmux?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Завершить cmux?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvoriti cmux?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إنهاء cmux؟" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avslutte cmux?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Encerrar o cmux?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ออกจาก cmux หรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux'tan çıkılsın mı?" + } + } + } + }, + "dialog.renameTab.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a custom name for this tab." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このタブのカスタム名を入力してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请为此标签页输入自定义名称。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為此標籤頁輸入自訂名稱。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 탭의 사용자 지정 이름을 입력하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie einen benutzerdefinierten Namen für diesen Tab ein." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce un nombre personalizado para esta pestaña." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez un nom personnalisé pour cet onglet." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci un nome personalizzato per questa scheda." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indtast et brugerdefineret navn til denne fane." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wprowadź własną nazwę dla tej karty." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите пользовательское имя для этой вкладки." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Unesite prilagođeni naziv za ovaj tab." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل اسمًا مخصصًا لهذا اللسان." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skriv inn et egendefinert navn for denne fanen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Insira um nome personalizado para esta aba." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป้อนชื่อที่กำหนดเองสำหรับแท็บนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu sekme için özel bir ad girin." + } + } + } + }, + "dialog.renameTab.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ名" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁名稱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 이름" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab-Name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre de pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nome scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fanenavn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nazwa karty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя вкладки" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naziv taba" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fanenavn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nome da aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ชื่อแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme adı" + } + } + } + }, + "dialog.renameTab.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブの名称変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 이름 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab umbenennen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę karty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать вкладку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi fanen nytt navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Yeniden Adlandır" + } + } + } + }, + "dialog.renameWorkspace.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a custom name for this workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このワークスペースのカスタム名を入力してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请为此工作区输入自定义名称。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為此工作區輸入自訂名稱。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 작업 공간의 사용자 지정 이름을 입력하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie einen benutzerdefinierten Namen für diesen Arbeitsbereich ein." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce un nombre personalizado para este espacio de trabajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez un nom personnalisé pour cet espace de travail." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci un nome personalizzato per questa area di lavoro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indtast et brugerdefineret navn til dette arbejdsområde." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wprowadź własną nazwę dla tej przestrzeni roboczej." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите пользовательское имя для этого рабочего пространства." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Unesite prilagođeni naziv za ovaj radni prostor." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل اسمًا مخصصًا لمساحة العمل هذه." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skriv inn et egendefinert navn for dette arbeidsområdet." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Insira um nome personalizado para esta área de trabalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป้อนชื่อที่กำหนดเองสำหรับเวิร์กสเปซนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu çalışma alanı için özel bir ad girin." + } + } + } + }, + "dialog.renameWorkspace.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區名稱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Name des Arbeitsbereichs" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre del espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nome area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområdenavn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nazwa przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naziv radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområdenavn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nome da área de trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ชื่อเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı adı" + } + } + } + }, + "dialog.renameWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの名称変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich umbenennen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi arbeidsområdet nytt navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Yeniden Adlandır" + } + } + } + }, + "error.clipboardFolderPath": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load any folder path from the clipboard." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリップボードからフォルダパスを読み込めませんでした。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法从剪贴板加载文件夹路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法從剪貼簿載入資料夾路徑。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드에서 폴더 경로를 불러올 수 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es konnte kein Ordnerpfad aus der Zwischenablage geladen werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo cargar ninguna ruta de carpeta desde el portapapeles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de charger un chemin de dossier depuis le presse-papiers." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile caricare un percorso cartella dagli appunti." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke indlæse nogen mappesti fra udklipsholderen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się wczytać ścieżki folderu ze schowka." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось загрузить путь к папке из буфера обмена." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće učitati putanju foldera iz međuspremnika." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تحميل أي مسار مجلد من الحافظة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke laste inn noen mappesti fra utklippstavlen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível carregar nenhum caminho de pasta da área de transferência." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถโหลดเส้นทางโฟลเดอร์จากคลิปบอร์ดได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Panodan herhangi bir klasör yolu yüklenemedi." + } + } + } + }, + "language.system": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Systemowy" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системный" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sistemski" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النظام" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระบบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem" + } + } + } + }, + "menu.app.about": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxについて" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关于 cmux" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關於 cmux" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux에 관하여" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Über cmux" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acerca de cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "À propos de cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Informazioni su cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Om cmux" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "O cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "О программе cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "O programu cmux" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حول cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Om cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sobre o cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เกี่ยวกับ cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux Hakkında" + } + } + } + }, + "menu.app.checkForUpdates": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを確認…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查更新..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查更新..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 확인…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach Updates suchen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar actualizaciones…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher des mises à jour..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Verifica aggiornamenti…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg efter opdateringer…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdź aktualizacje…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверить обновления..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Provjeri ažuriranja…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق من التحديثات…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Se etter oppdateringer …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar Atualizações…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตรวจหาอัปเดต..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeleri Denetle…" + } + } + } + }, + "menu.app.ghosttySettings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ghostty Settings…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Ghostty設定…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Ghostty 设置..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Ghostty 設定..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Ghostty 설정…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ghostty-Einstellungen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes de Ghostty…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réglages Ghostty..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impostazioni Ghostty…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ghostty-indstillinger…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ustawienia Ghostty…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки Ghostty..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ghostty postavke…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعدادات Ghostty…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ghostty-innstillinger …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ajustes do Ghostty…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การตั้งค่า Ghostty..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ghostty Ayarları…" + } + } + } + }, + "menu.app.reloadConfiguration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload Configuration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "構成を再読み込み" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구성 다시 불러오기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration neu laden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recargar configuración" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recharger la configuration" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ricarica configurazione" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genindlæs konfiguration" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odśwież konfigurację" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезагрузить конфигурацию" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo učitaj konfiguraciju" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تحميل الإعدادات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Last inn konfigurasjon på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recarregar Configuração" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหลดการกำหนดค่าใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yapılandırmayı Yeniden Yükle" + } + } + } + }, + "menu.app.settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réglages..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impostazioni…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indstillinger…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ustawienia…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Postavke…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإعدادات…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Innstillinger …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ajustes…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การตั้งค่า..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarlar…" + } + } + } + }, + "menu.checkForUpdates": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを確認…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查更新..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查更新..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 확인…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach Updates suchen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar actualizaciones…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher des mises à jour..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Verifica aggiornamenti…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg efter opdateringer…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdź aktualizacje…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверить обновления..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Provjeri ažuriranja…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق من التحديثات…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Se etter oppdateringer …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar Atualizações…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตรวจหาอัปเดต..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeleri Denetle…" + } + } + } + }, + "menu.currentWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana actual" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre actuelle" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra corrente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nuværende vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Bieżące okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текущее окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Trenutni prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النافذة الحالية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjeldende vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela Atual" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่างปัจจุบัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Pencere" + } + } + } + }, + "menu.file.closeOtherTabs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Other Tabs in Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペイン内の他のタブを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭面板中的其他标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉面板中的其他標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "패널의 다른 탭 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Andere Tabs im Bereich schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar otras pestañas del panel" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer les autres onglets du panneau" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi altre schede nel pannello" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk andre faner i panel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij inne karty w panelu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть другие вкладки в панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori ostale tabove u panelu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق الألسنة الأخرى في اللوحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk andre faner i panelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Outras Abas no Painel" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดแท็บอื่นในบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölmedeki Diğer Sekmeleri Kapat" + } + } + } + }, + "menu.file.closeTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij kartę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть вкладку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Kapat" + } + } + } + }, + "menu.file.closeWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij przestrzeń roboczą" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Kapat" + } + } + } + }, + "menu.file.commandPalette": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Palette…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドパレット…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令面板..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "指令面板..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "명령어 팔레트…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Befehlspalette …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Paleta de comandos…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Palette de commandes..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tavolozza comandi…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kommandopalette…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Paleta poleceń…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Палитра команд..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Paleta naredbi…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لوحة الأوامر…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kommandopalett …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Paleta de Comandos…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แถบคำสั่ง..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Komut Paleti…" + } + } + } + }, + "menu.file.goToWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Go to Workspace…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースに移動…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "前往工作区..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "前往工作區..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간으로 이동…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Arbeitsbereich wechseln …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ir al espacio de trabajo…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aller à l'espace de travail..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vai all'area di lavoro…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gå til arbejdsområde…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przejdź do przestrzeni roboczej…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к рабочему пространству..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Idi na radni prostor…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتقال إلى مساحة العمل…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gå til arbeidsområde …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ir para Área de Trabalho…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไปที่เวิร์กสเปซ..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanına Git…" + } + } + } + }, + "menu.file.newWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neues Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowe okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่างใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Pencere" + } + } + } + }, + "menu.file.newWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Çalışma Alanı" + } + } + } + }, + "menu.file.openFolder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Folder…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを開く…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开文件夹..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟資料夾..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 열기…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordner öffnen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir carpeta…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir un dossier..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri cartella…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn mappe…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz folder…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть папку..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori folder…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح مجلد…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne mappe …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Pasta…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดโฟลเดอร์..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasör Aç…" + } + } + } + }, + "menu.file.openFolder.panelPrompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aç" + } + } + } + }, + "menu.file.openFolder.panelTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟資料夾" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordner öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir carpeta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir un dossier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri cartella" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn mappe" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz folder" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть папку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori folder" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح مجلد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne mappe" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Pasta" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดโฟลเดอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasör Aç" + } + } + } + }, + "menu.file.reopenClosedBrowserPanel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reopen Closed Browser Panel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じたブラウザパネルを再度開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新打开已关闭的浏览器面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新開啟已關閉的瀏覽器面板" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫은 브라우저 패널 다시 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschlossenes Browserfenster erneut öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reabrir panel del navegador cerrado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rouvrir le panneau de navigateur fermé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riapri pannello browser chiuso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genåbn lukket browserpanel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz ponownie zamknięty panel przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть закрытую панель браузера" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo otvori zatvoreni panel preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة فتح لوحة المتصفح المغلقة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne lukket nettleserpanel på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reabrir Painel do Navegador Fechado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดแผงเบราว์เซอร์ที่ปิดไปอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapatılan Tarayıcı Panelini Yeniden Aç" + } + } + } + }, + "menu.find.find": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查找..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尋找..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "찾기…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trova…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Znajdź…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Найти..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pronađi…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بحث…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Finn …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหา..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bul…" + } + } + } + }, + "menu.find.findNext": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find Next" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次を検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查找下一个" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尋找下一個" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 찾기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weitersuchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar siguiente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat suivant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trova successivo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Find næste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Znajdź następny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Найти далее" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pronađi sljedeće" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث عن التالي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Finn neste" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar Próximo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหาถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonrakini Bul" + } + } + } + }, + "menu.find.findPrevious": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find Previous" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前を検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查找上一个" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尋找上一個" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 찾기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorheriges suchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat précédent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trova precedente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Find forrige" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Znajdź poprzedni" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Найти ранее" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pronađi prethodno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث عن السابق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Finn forrige" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar Anterior" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหาก่อนหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Öncekini Bul" + } + } + } + }, + "menu.find.hideFindBar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Find Bar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索バーを非表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏查找栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏尋找列" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "찾기 막대 숨기기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchleiste ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar barra de búsqueda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer la barre de recherche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nascondi barra di ricerca" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skjul søgelinje" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ukryj pasek wyszukiwania" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть панель поиска" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sakrij traku za pretragu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخفاء شريط البحث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skjul søkelinje" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ocultar Barra de Busca" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ซ่อนแถบค้นหา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Arama Çubuğunu Gizle" + } + } + } + }, + "menu.find.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查找" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尋找" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "찾기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trova" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Znajdź" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pronađi" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بحث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Finn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bul" + } + } + } + }, + "menu.find.useSelectionForFind": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Selection for Find" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択範囲を検索に使用" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用选中内容查找" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用所選範圍來尋找" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택 항목을 찾기에 사용" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auswahl für Suche verwenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usar selección para buscar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utiliser la sélection pour la recherche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Usa selezione per la ricerca" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brug markering til søgning" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użyj zaznaczenia do wyszukiwania" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использовать выделение для поиска" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Koristi odabrano za pretragu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدام التحديد للبحث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk utvalg for søk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Usar Seleção para Busca" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้ข้อความที่เลือกเพื่อค้นหา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Seçimi Bulmak İçin Kullan" + } + } + } + }, + "menu.notifications.clearAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear All" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてクリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部清除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除全部" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두 지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar todo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout effacer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella tutto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd alle" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść wszystko" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить все" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši sve" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح الكل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern alle" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Tudo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างทั้งหมด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tümünü Temizle" + } + } + } + }, + "menu.notifications.jumpToUnread": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to Latest Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の未読にジャンプ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳转到最新未读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跳至最新未讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신 읽지 않은 항목으로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zur letzten ungelesenen springen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ir a la última no leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aller au dernier message non lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vai all'ultimo non letto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gå til seneste ulæste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przejdź do najnowszego nieprzeczytanego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к последнему непрочитанному" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Skoči na najnovije nepročitano" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتقال إلى أحدث غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gå til siste uleste" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ir para Última Não Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้ามไปยังรายการยังไม่อ่านล่าสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Son Okunmamışa Atla" + } + } + } + }, + "menu.notifications.markAllRead": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark All Read" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて既読にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部标记为已读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部標為已讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두 읽음으로 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle als gelesen markieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Marcar todo como leído" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout marquer comme lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segna tutto come letto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Marker alle som læste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oznacz wszystko jako przeczytane" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить все как прочитанные" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi sve kao pročitano" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليم الكل كمقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Merk alle som lest" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Marcar Tudo como Lido" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำเครื่องหมายว่าอ่านทั้งหมดแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tümünü Okundu İşaretle" + } + } + } + }, + "menu.notifications.show": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimleri Göster" + } + } + } + }, + "menu.notifications.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimler" + } + } + } + }, + "menu.openInAndroidStudio": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Android Studio" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Android Studio で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Android Studio 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Android Studio 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Android Studio에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Android Studio öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Android Studio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Android Studio" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Android Studio" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Android Studio" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Android Studio" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Android Studio" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Android Studio" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Android Studio" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Android Studio" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Android Studio" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Android Studio" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Android Studio'da Aç" + } + } + } + }, + "menu.openInAntigravity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Antigravity" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Antigravity で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Antigravity 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Antigravity 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Antigravity에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Antigravity öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Antigravity" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Antigravity" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Antigravity" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Antigravity" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Antigravity" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Antigravity" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Antigravity" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Antigravity" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Antigravity" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Antigravity" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Antigravity" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Antigravity'de Aç" + } + } + } + }, + "menu.openInCursor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Cursor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Cursor で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Cursor 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Cursor 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Cursor에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Cursor öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Cursor" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Cursor" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Cursor" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Cursor" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Cursor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Cursor" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Cursor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Cursor" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Cursor" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Cursor" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Cursor" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Cursor'da Aç" + } + } + } + }, + "menu.openInFinder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Finder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Finder で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在访达中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Finder 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Finder에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis im Finder öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Finder" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans le Finder" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente nel Finder" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Finder" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Finderze" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Finder" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Finder" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Finder" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Finder" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Finder" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Finder" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Finder'da Aç" + } + } + } + }, + "menu.openInGhostty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Ghostty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Ghostty で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Ghostty 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Ghostty 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Ghostty에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Ghostty öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Ghostty" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Ghostty" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Ghostty" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Ghostty" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Ghostty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Ghostty" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Ghostty" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Ghostty" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Ghostty" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Ghostty" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Ghostty" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Ghostty'de Aç" + } + } + } + }, + "menu.openInITerm2": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in iTerm2" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを iTerm2 で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 iTerm2 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 iTerm2 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 iTerm2에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in iTerm2 öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en iTerm2" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans iTerm2" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in iTerm2" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i iTerm2" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w iTerm2" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в iTerm2" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u iTerm2" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في iTerm2" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i iTerm2" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no iTerm2" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน iTerm2" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini iTerm2'de Aç" + } + } + } + }, + "menu.openInTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリをターミナルで開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在终端中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在「終端機」中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 터미널에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Terminal öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Terminale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Terminal" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Terminalu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Терминале" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Terminal" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Terminal" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Terminal" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Terminal" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Terminal" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Terminal'de Aç" + } + } + } + }, + "menu.openInTower": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Tower" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Tower で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Tower 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Tower 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Tower에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Tower öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Tower" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Tower" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Tower" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Tower" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Tower" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Tower" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Tower" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Tower" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Tower" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Tower" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Tower" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Tower'da Aç" + } + } + } + }, + "menu.openInVSCode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in VS Code (Inline)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを VS Code で開く(インライン)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 VS Code(内联)中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 VS Code(內嵌)中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 VS Code (인라인)에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in VS Code (Inline) öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en VS Code (en línea)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans VS Code (intégré)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in VS Code (Inline)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i VS Code (Inline)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w VS Code (wbudowany)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в VS Code (встроенный)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u VS Code (Inline)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في VS Code (مضمّن)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i VS Code (innebygd)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no VS Code (Inline)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน VS Code (แบบอินไลน์)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini VS Code'da Aç (Satır İçi)" + } + } + } + }, + "menu.openInWarp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Warp" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Warp で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Warp 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Warp 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Warp에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Warp öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Warp" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Warp" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Warp" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Warp" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Warp" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Warp" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Warp" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Warp" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Warp" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Warp" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Warp" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Warp'ta Aç" + } + } + } + }, + "menu.openInWindsurf": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Windsurf" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Windsurf で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Windsurf 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Windsurf 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Windsurf에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Windsurf öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Windsurf" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Windsurf" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Windsurf" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Windsurf" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Windsurf" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Windsurf" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Windsurf" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Windsurf" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Windsurf" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Windsurf" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Windsurf" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Windsurf'te Aç" + } + } + } + }, + "menu.openInXcode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Xcode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Xcode で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Xcode 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Xcode 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Xcode에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Xcode öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Xcode" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Xcode" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Xcode" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Xcode" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Xcode" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Xcode" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Xcode" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Xcode" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Xcode" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Xcode" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Xcode" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Xcode'da Aç" + } + } + } + }, + "menu.openInZed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Current Directory in Zed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のディレクトリを Zed で開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Zed 中打开当前目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Zed 中開啟目前目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디렉토리를 Zed에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelles Verzeichnis in Zed öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir directorio actual en Zed" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le répertoire courant dans Zed" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri la directory corrente in Zed" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn nuværende mappe i Zed" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz bieżący katalog w Zed" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть текущий каталог в Zed" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori trenutni direktorij u Zed" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الدليل الحالي في Zed" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne gjeldende katalog i Zed" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Diretório Atual no Zed" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดไดเรกทอรีปัจจุบันใน Zed" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli Dizini Zed'de Aç" + } + } + } + }, + "menu.preferences": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preferences…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "偏好设置..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偏好設定..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "환경설정…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preferencias…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préférences..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Preferenze…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indstillinger…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Preferencje…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Postavke…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التفضيلات…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Innstillinger …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Preferências…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การตั้งค่า..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tercihler…" + } + } + } + }, + "menu.quitCmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux を終了" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出 cmux" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結束 cmux" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 종료" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux beenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Salir de cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quitter cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Esci da cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Afslut cmux" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zakończ cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Завершить cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori cmux" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إنهاء cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avslutt cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Encerrar o cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ออกจาก cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux'tan Çık" + } + } + } + }, + "menu.showNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimleri Göster" + } + } + } + }, + "menu.updateLogs.copyFocusLogs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Focus Logs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカスログをコピー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拷贝焦点日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拷貝焦點記錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포커스 로그 복사" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokus-Protokolle kopieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar registros de enfoque" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier les journaux de focus" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Copia log di focus" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kopier fokuslogfiler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kopiuj dzienniki fokusu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопировать журнал фокуса" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kopiraj logove fokusa" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ سجلات التركيز" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kopier fokuslogger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar Logs de Foco" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คัดลอกบันทึกการโฟกัส" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Odak Günlüklerini Kopyala" + } + } + } + }, + "menu.updateLogs.copyUpdateLogs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Update Logs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートログをコピー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拷贝更新日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拷貝更新記錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 로그 복사" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update-Protokolle kopieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar registros de actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier les journaux de mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Copia log aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kopier opdateringslogfiler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kopiuj dzienniki aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопировать журнал обновлений" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kopiraj logove ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ سجلات التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kopier oppdateringslogger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar Logs de Atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คัดลอกบันทึกการอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Günlüklerini Kopyala" + } + } + } + }, + "menu.view.actualSize": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Actual Size" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実際のサイズ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实际大小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "實際大小" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실제 크기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Originalgröße" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tamaño real" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille réelle" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dimensione reale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Faktisk størrelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozmiar rzeczywisty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фактический размер" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Stvarna veličina" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحجم الفعلي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Faktisk størrelse" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tamanho Real" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดจริง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gerçek Boyut" + } + } + } + }, + "menu.view.back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻る" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "后退" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "뒤로" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Atrás" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Précédent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Indietro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilbage" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wstecz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Назад" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nazad" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رجوع" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilbake" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Voltar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้อนกลับ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geri" + } + } + } + }, + "menu.view.clearBrowserHistory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Browser History" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ履歴をクリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除浏览器历史记录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除瀏覽記錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 기록 지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browserverlauf löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar historial del navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer l'historique du navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella cronologia browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd browserhistorik" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść historię przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить историю браузера" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši historiju preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح سجل المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tøm nettleserhistorikk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Histórico do Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างประวัติเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı Geçmişini Temizle" + } + } + } + }, + "menu.view.importFromBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import From Browser…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザから取り込む…" + } + } + } + }, + "menu.view.forward": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Forward" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "進む" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "前进" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앞으로" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vor" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Adelante" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avanti" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Frem" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do przodu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вперед" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naprijed" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقدم" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fremover" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Avançar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไปข้างหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İleri" + } + } + } + }, + "menu.view.jumpToUnread": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to Latest Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の未読にジャンプ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳转到最新未读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跳至最新未讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신 읽지 않은 항목으로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zur letzten ungelesenen springen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ir a la última no leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aller au dernier message non lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vai all'ultimo non letto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gå til seneste ulæste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przejdź do najnowszego nieprzeczytanego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к последнему непрочитанному" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Skoči na najnovije nepročitano" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتقال إلى أحدث غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gå til siste uleste" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ir para Última Não Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้ามไปยังรายการยังไม่อ่านล่าสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Son Okunmamışa Atla" + } + } + } + }, + "menu.view.nextSurface": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next Surface" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のサーフェス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一个 Surface" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一個 Surface" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 화면" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächste Oberfläche" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Siguiente superficie" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surface suivante" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Superficie successiva" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Næste overflade" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Następna powierzchnia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следующая поверхность" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sljedeća površina" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "السطح التالي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Neste flate" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Próxima Superfície" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นผิวถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonraki Yüzey" + } + } + } + }, + "menu.view.nextWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一个工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一個工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächster Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Siguiente espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail suivant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro successiva" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Næste arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Następna przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следующее рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sljedeći radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل التالية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Neste arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Próxima Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonraki Çalışma Alanı" + } + } + } + }, + "menu.view.previousSurface": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous Surface" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前のサーフェス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一个 Surface" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一個 Surface" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 화면" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorherige Oberfläche" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Superficie anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surface précédente" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Superficie precedente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forrige overflade" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Poprzednia powierzchnia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущая поверхность" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prethodna površina" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "السطح السابق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forrige flate" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Superfície Anterior" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นผิวก่อนหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önceki Yüzey" + } + } + } + }, + "menu.view.previousWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前のワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一个工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一個工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorheriger Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail précédent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro precedente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forrige arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Poprzednia przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущее рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prethodni radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل السابقة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forrige arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho Anterior" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซก่อนหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önceki Çalışma Alanı" + } + } + } + }, + "menu.view.reloadPage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload Page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページを再読み込み" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入頁面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 새로고침" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seite neu laden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recargar página" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recharger la page" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ricarica pagina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genindlæs side" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odśwież stronę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезагрузить страницу" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo učitaj stranicu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تحميل الصفحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Last inn siden på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recarregar Página" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหลดหน้าใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sayfayı Yeniden Yükle" + } + } + } + }, + "menu.view.renameWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Workspace…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの名称変更…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名工作区..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名工作區..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름 변경…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich umbenennen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar espacio de trabajo…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'espace de travail..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina area di lavoro…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb arbejdsområde…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę przestrzeni roboczej…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать рабочее пространство..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj radni prostor…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية مساحة العمل…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi arbeidsområdet nytt navn …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Área de Trabalho…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อเวิร์กสเปซ..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Yeniden Adlandır…" + } + } + } + }, + "menu.view.showJSConsole": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show JavaScript Console" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "JavaScriptコンソールを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示 JavaScript 控制台" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示 JavaScript 主控台" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "JavaScript 콘솔 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "JavaScript-Konsole anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar consola de JavaScript" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la console JavaScript" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra console JavaScript" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis JavaScript-konsol" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż konsolę JavaScript" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать консоль JavaScript" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži JavaScript konzolu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض وحدة تحكم JavaScript" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis JavaScript-konsoll" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Console JavaScript" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงคอนโซล JavaScript" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "JavaScript Konsolunu Göster" + } + } + } + }, + "menu.view.showNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimleri Göster" + } + } + } + }, + "menu.view.splitBrowserDown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Browser Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを下に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向下拆分浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向下分割瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저를 아래로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser nach unten teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir navegador hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser le navigateur vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi browser in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel browser nedad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel przeglądarkę w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить браузер вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli preglednik dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم المتصفح للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del nettleser ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir Navegador para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกเบราว์เซอร์ลงล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Aşağı Böl" + } + } + } + }, + "menu.view.splitBrowserRight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Browser Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを右に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右拆分浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右分割瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저를 오른쪽으로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser nach rechts teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir navegador a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser le navigateur à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi browser a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel browser til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel przeglądarkę w prawo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить браузер вправо" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli preglednik desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم المتصفح لليمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del nettleser til høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir Navegador à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกเบราว์เซอร์ไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Sağa Böl" + } + } + } + }, + "menu.view.splitDown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向下拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向下分割" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach unten teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel nedad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกลงล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aşağı Böl" + } + } + } + }, + "menu.view.splitRight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右分割" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽으로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach rechts teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel w prawo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить вправо" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم لليمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del til høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sağa Böl" + } + } + } + }, + "menu.view.toggleDevTools": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Developer Tools" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デベロッパツールの切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换开发者工具" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換開發者工具" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개발자 도구 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entwicklerwerkzeuge ein-/ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar herramientas de desarrollo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher/masquer les outils de développement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva Strumenti sviluppatore" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå udviklerværktøjer til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz narzędzia deweloperskie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Инструменты разработчика" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci razvojne alate" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل أدوات المطور" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå utviklerverktøy av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Ferramentas do Desenvolvedor" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับเครื่องมือนักพัฒนา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geliştirici Araçlarını Aç/Kapat" + } + } + } + }, + "menu.view.toggleSidebar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーの切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换侧边栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換側邊欄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste ein-/ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher/masquer la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå sidebjælke til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz pasek boczny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Боковая панель" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci bočnu traku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå sidepanelet av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunu Aç/Kapat" + } + } + } + }, + "menu.view.workspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース %lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区 %lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區 %lld" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 %lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich %lld" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo %lld" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail %lld" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro %lld" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde %lld" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza %lld" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство %lld" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor %lld" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل %lld" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde %lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho %lld" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ %lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı %lld" + } + } + } + }, + "menu.view.zoomIn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zoom In" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡大" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "放大" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "放大" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확대" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einzoomen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ampliar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Zoom avant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ingrandisci" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Zoom ind" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiększ" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Увеличить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uvećaj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تكبير" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Zoom inn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aumentar Zoom" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ซูมเข้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yakınlaştır" + } + } + } + }, + "menu.view.zoomOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zoom Out" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "縮小" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮小" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "축소" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auszoomen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reducir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Zoom arrière" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riduci" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Zoom ud" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pomniejsz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уменьшить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Umanji" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تصغير" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Zoom ut" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Diminuir Zoom" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ซูมออก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzaklaştır" + } + } + } + }, + "menu.windowNumber": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ %lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口 %lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗 %lld" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 %lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster %lld" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana %lld" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre %lld" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra %lld" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vindue %lld" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Okno %lld" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно %lld" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozor %lld" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النافذة %lld" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindu %lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela %lld" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่าง %lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere %lld" + } + } + } + }, + "notifications.clearAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear All" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてクリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部清除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除全部" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두 지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar todo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout effacer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella tutto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd alle" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść wszystko" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить все" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši sve" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح الكل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern alle" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Tudo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างทั้งหมด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tümünü Temizle" + } + } + } + }, + "notifications.empty.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop notifications will appear here for quick review." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デスクトップ通知がここに表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "桌面通知将在此处显示,方便快速查看。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "桌面通知將在這裡顯示,方便您快速查看。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데스크톱 알림이 여기에 표시되어 빠르게 확인할 수 있습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Desktop-Benachrichtigungen werden hier zur schnellen Überprüfung angezeigt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las notificaciones de escritorio aparecerán aquí para su revisión rápida." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les notifications de bureau apparaîtront ici pour une consultation rapide." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le notifiche desktop appariranno qui per una rapida consultazione." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skrivebordsnotifikationer vises her til hurtig gennemgang." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia pulpitu będą się tu pojawiać do szybkiego przeglądu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления рабочего стола будут отображаться здесь для быстрого просмотра." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja na radnoj površini će se pojavljivati ovdje radi brzog pregleda." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ستظهر إشعارات سطح المكتب هنا للمراجعة السريعة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skrivebordsvarsler vises her for rask gjennomgang." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As notificações da área de trabalho aparecerão aqui para revisão rápida." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือนเดสก์ท็อปจะปรากฏที่นี่เพื่อตรวจสอบอย่างรวดเร็ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Masaüstü bildirimleri hızlı inceleme için burada görünecek." + } + } + } + }, + "notifications.empty.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop notifications will appear here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デスクトップ通知がここに表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "桌面通知将在此处显示。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "桌面通知將在這裡顯示。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데스크톱 알림이 여기에 표시됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Desktop-Benachrichtigungen werden hier angezeigt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las notificaciones de escritorio aparecerán aquí." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les notifications de bureau apparaîtront ici." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le notifiche desktop appariranno qui." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skrivebordsnotifikationer vises her." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia pulpitu będą się tu pojawiać." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления рабочего стола будут отображаться здесь." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja na radnoj površini će se pojavljivati ovdje." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ستظهر إشعارات سطح المكتب هنا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skrivebordsvarsler vises her." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As notificações da área de trabalho aparecerão aqui." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือนเดสก์ท็อปจะปรากฏที่นี่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Masaüstü bildirimleri burada görünecek." + } + } + } + }, + "notifications.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No notifications yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだ通知はありません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚無通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 알림이 없습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune notification pour le moment" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna notifica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen notifikationer endnu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak powiadomień" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомлений пока нет" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Još nema obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد إشعارات بعد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen varsler ennå" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma notificação ainda" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่มีการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Henüz bildirim yok" + } + } + } + }, + "notifications.jumpToLatest": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to Latest" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新へジャンプ" + } + } + } + }, + "notifications.jumpToLatestUnread": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to Latest Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の未読にジャンプ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳转到最新未读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跳至最新未讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신 읽지 않은 항목으로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zur letzten ungelesenen springen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ir a la última no leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aller au dernier message non lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vai all'ultimo non letto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gå til seneste ulæste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przejdź do najnowszego nieprzeczytanego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к последнему непрочитанному" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Skoči na najnovije nepročitano" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتقال إلى أحدث غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gå til siste uleste" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ir para Última Não Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้ามไปยังรายการยังไม่อ่านล่าสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Son Okunmamışa Atla" + } + } + } + }, + "notifications.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimler" + } + } + } + }, + "panel.displayName.fallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вкладка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme" + } + } + } + }, + "panel.openFolder.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aç" + } + } + } + }, + "panel.openFolder.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟資料夾" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordner öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir carpeta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir un dossier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri cartella" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn mappe" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz folder" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть папку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori folder" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح مجلد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne mappe" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Pasta" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดโฟลเดอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasör Aç" + } + } + } + }, + "search.close.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close (Esc)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる (Esc)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭 (Esc)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉 (Esc)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫기 (Esc)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen (Esc)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar (Esc)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer (Échap)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi (Esc)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk (Esc)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij (Esc)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть (Esc)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori (Esc)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق (Esc)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk (Esc)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar (Esc)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิด (Esc)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapat (Esc)" + } + } + } + }, + "search.nextMatch.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next match (Return)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次の一致 (Return)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一个匹配项 (Return)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一個符合項目 (Return)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 일치 (Return)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächster Treffer (Eingabetaste)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Siguiente coincidencia (Return)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat suivant (Entrée)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Risultato successivo (Invio)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Næste match (Return)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Następne dopasowanie (Return)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следующее совпадение (Return)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sljedeći rezultat (Return)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التطابق التالي (Return)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Neste treff (Return)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Próximo resultado (Return)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ผลลัพธ์ถัดไป (Return)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonraki eşleşme (Return)" + } + } + } + }, + "search.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cerca" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szukaj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pretraži" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بحث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Søk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ara" + } + } + } + }, + "search.previousMatch.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous match (Shift+Return)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前の一致 (Shift+Return)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一个匹配项 (Shift+Return)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一個符合項目 (Shift+Return)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 일치 (Shift+Return)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorheriger Treffer (Umschalt+Eingabetaste)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Coincidencia anterior (Shift+Return)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat précédent (Maj+Entrée)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Risultato precedente (Maiusc+Invio)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forrige match (Shift+Return)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Poprzednie dopasowanie (Shift+Return)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущее совпадение (Shift+Return)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prethodni rezultat (Shift+Return)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التطابق السابق (Shift+Return)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forrige treff (Shift+Return)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Resultado anterior (Shift+Return)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ผลลัพธ์ก่อนหน้า (Shift+Return)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önceki eşleşme (Shift+Return)" + } + } + } + }, + "settings.app.appIcon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App Icon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアイコン" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用图标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "App 圖示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 아이콘" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Symbol" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ícono de la app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Icône de l'app" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Icona app" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Appikon" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ikona aplikacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Значок приложения" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ikona aplikacije" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أيقونة التطبيق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Appikon" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ícone do App" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไอคอนแอป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uygulama Simgesi" + } + } + } + }, + "settings.app.dockBadge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dock Badge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Dockバッジ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "程序坞角标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Dock 標記" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Dock 배지" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dock-Badge" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Insignia del Dock" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Badge du Dock" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Badge del Dock" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dock-badge" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Plakietka w Docku" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Значок Dock" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Oznaka na Docku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "شارة Dock" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dock-merke" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Emblema do Dock" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป้าย Dock" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dock Rozeti" + } + } + } + }, + "settings.app.dockBadge.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show unread count on app icon (Dock and Cmd+Tab)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアイコン(DockおよびCmd+Tab)に未読数を表示します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在应用图标上显示未读计数(程序坞和 Cmd+Tab)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 App 圖示上顯示未讀數量(Dock 和 Cmd+Tab)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 아이콘(Dock 및 Cmd+Tab)에 읽지 않은 수를 표시합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungelesene Anzahl auf dem App-Symbol anzeigen (Dock und Cmd+Tab)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar el recuento de no leídos en el ícono de la app (Dock y Cmd+Tab)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher le nombre de messages non lus sur l'icône de l'app (Dock et Cmd+Tab)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra il conteggio non letti sull'icona dell'app (Dock e Cmd+Tab)." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis antal ulæste på appikonet (Dock og Cmd+Tab)." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż liczbę nieprzeczytanych na ikonie aplikacji (Dock i Cmd+Tab)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать количество непрочитанных на значке приложения (Dock и Cmd+Tab)." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži broj nepročitanih na ikoni aplikacije (Dock i Cmd+Tab)." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض عدد غير المقروء على أيقونة التطبيق (Dock و Cmd+Tab)." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis antall uleste på appikonet (Dock og Cmd+Tab)." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar contagem de não lidos no ícone do app (Dock e Cmd+Tab)." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงจำนวนยังไม่อ่านบนไอคอนแอป (Dock และ Cmd+Tab)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uygulama simgesinde (Dock ve Cmd+Tab) okunmamış sayısını göster." + } + } + } + }, + "settings.app.language": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언어" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sprache" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Idioma" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Langue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Lingua" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sprog" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Język" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Язык" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Jezik" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اللغة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Språk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Idioma" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ภาษา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dil" + } + } + } + }, + "settings.app.language.restartDialog.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart Now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今すぐ再起動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即重启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即重新啟動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 재시작" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar ahora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer maintenant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia ora" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart nu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie teraz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить сейчас" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokreni sada" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التشغيل الآن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start på nytt nå" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar Agora" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ตเดี๋ยวนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şimdi Yeniden Başlat" + } + } + } + }, + "settings.app.language.restartDialog.later": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Later" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "後で" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "稍后" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "稍後" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나중에" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Später" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Más tarde" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Plus tard" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Più tardi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Senere" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Później" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Позже" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kasnije" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لاحقًا" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Senere" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Depois" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ภายหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Daha Sonra" + } + } + } + }, + "settings.app.language.restartDialog.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart to apply language change?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語変更を適用するために再起動しますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启以应用语言更改?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要重新啟動以套用語言變更嗎?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언어 변경을 적용하려면 재시작하시겠습니까?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu starten, um Sprachänderung zu übernehmen?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Reiniciar para aplicar el cambio de idioma?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer pour appliquer le changement de langue ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavviare per applicare il cambio di lingua?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart for at anvende sprogændring?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchomić ponownie, aby zastosować zmianę języka?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить для применения языка?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokrenuti za primjenu promjene jezika?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التشغيل لتطبيق تغيير اللغة؟" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Starte på nytt for å endre språk?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar para aplicar a mudança de idioma?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ตเพื่อเปลี่ยนภาษาหรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dil değişikliğini uygulamak için yeniden başlatılsın mı?" + } + } + } + }, + "settings.app.language.restartSubtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart cmux to apply" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxを再起動して適用" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启 cmux 以应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動 cmux 以套用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "적용하려면 cmux를 재시작하세요" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux neu starten, um Änderung zu übernehmen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reinicia cmux para aplicar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrez cmux pour appliquer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia cmux per applicare" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart cmux for at anvende" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie cmux, aby zastosować" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустите cmux для применения" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokrenite cmux za primjenu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أعد تشغيل cmux للتطبيق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start cmux på nytt for å bruke" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reinicie o cmux para aplicar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ต cmux เพื่อนำไปใช้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uygulamak için cmux'u yeniden başlatın" + } + } + } + }, + "settings.app.newWorkspacePlacement": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace Placement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ワークスペースの配置" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新工作区位置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新工作區位置" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 작업 공간 위치" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Platzierung neuer Arbeitsbereiche" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ubicación de nuevo espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Emplacement des nouveaux espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Posizionamento nuova area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Placering af nyt arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Umieszczenie nowej przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расположение нового рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pozicija novog radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "موضع مساحة العمل الجديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Plassering av nytt arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Posição da Nova Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตำแหน่งเวิร์กสเปซใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Çalışma Alanı Konumu" + } + } + } + }, + "settings.app.openSidebarPRLinks": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Sidebar PR Links in cmux Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーのPRリンクをcmuxブラウザで開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 浏览器中打开侧边栏 PR 链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 瀏覽器中開啟側邊欄 PR 連結" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 PR 링크를 cmux 브라우저에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleisten-PR-Links im cmux-Browser öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir enlaces de PR de la barra lateral en el navegador de cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les liens PR de la barre latérale dans le navigateur cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri link PR della barra laterale nel browser cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn sidebjælkens PR-links i cmux-browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwieraj linki PR z paska bocznego w przeglądarce cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открывать ссылки PR боковой панели в браузере cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori PR linkove iz bočne trake u cmux pregledniku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح روابط طلبات السحب في متصفح cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne sidepanel-PR-lenker i cmux-nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Links de PR da Barra Lateral no Navegador do cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดลิงก์ PR ในแถบด้านข้างด้วยเบราว์เซอร์ cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğu PR Bağlantılarını cmux Tarayıcısında Aç" + } + } + } + }, + "settings.app.openSidebarPRLinks.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clicks open in your default browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリックするとデフォルトブラウザで開きます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击在默认浏览器中打开。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊會在您的預設瀏覽器中開啟。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클릭하면 기본 브라우저에서 열립니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klicks öffnen in Ihrem Standardbrowser." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los clics abren en tu navegador predeterminado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les clics ouvrent dans votre navigateur par défaut." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "I clic aprono nel browser predefinito." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Klik åbner i din standardbrowser." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kliknięcia otwierają w domyślnej przeglądarce." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ссылки открываются в браузере по умолчанию." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Klikovi se otvaraju u podrazumijevanom pregledniku." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النقرات تفتح في متصفحك الافتراضي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Klikk åpner i standard nettleser." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cliques abrem no seu navegador padrão." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คลิกจะเปิดในเบราว์เซอร์เริ่มต้นของคุณ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tıklamalar varsayılan tarayıcınızda açılır." + } + } + } + }, + "settings.app.openSidebarPRLinks.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clicks open inside cmux browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリックするとcmuxブラウザ内で開きます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击在 cmux 浏览器中打开。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊會在 cmux 瀏覽器中開啟。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클릭하면 cmux 브라우저에서 열립니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klicks öffnen im cmux-Browser." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los clics abren dentro del navegador de cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les clics ouvrent dans le navigateur cmux." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "I clic aprono nel browser cmux." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Klik åbner i cmux-browseren." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kliknięcia otwierają w przeglądarce cmux." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ссылки открываются во встроенном браузере cmux." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Klikovi se otvaraju unutar cmux preglednika." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النقرات تفتح داخل متصفح cmux." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Klikk åpner i cmux-nettleseren." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cliques abrem dentro do navegador do cmux." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คลิกจะเปิดในเบราว์เซอร์ cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tıklamalar cmux tarayıcısında açılır." + } + } + } + }, + "settings.app.renameSelectsName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Selects Existing Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名称変更時に既存の名前を選択" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名时选中现有名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名時選取現有名稱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름 변경 시 기존 이름 선택" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Umbenennen wählt vorhandenen Namen aus" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar selecciona el nombre existente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le renommage sélectionne le nom existant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina seleziona il nome esistente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøbning markerer eksisterende navn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmiana nazwy zaznacza istniejącą nazwę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименование выделяет имя" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenovanje odabere postojeći naziv" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التسمية تحدد الاسم الحالي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Omdøping velger eksisterende navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Seleciona o Nome Existente" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลือกชื่อเมื่อเปลี่ยนชื่อ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden Adlandırma Mevcut Adı Seçer" + } + } + } + }, + "settings.app.renameSelectsName.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Palette rename keeps the caret at the end." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドパレットの名称変更ではキャレットが末尾に置かれます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令面板重命名时光标保持在末尾。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "指令面板重新命名時,游標保持在結尾。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "명령어 팔레트의 이름 변경 시 커서가 끝에 위치합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Umbenennen in der Befehlspalette bleibt der Cursor am Ende." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar en la paleta de comandos mantiene el cursor al final." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le renommage via la palette de commandes maintient le curseur à la fin." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La rinomina nella Tavolozza comandi mantiene il cursore alla fine." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøbning i kommandopaletten beholder markøren til sidst." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmiana nazwy w palecie poleceń pozostawia kursor na końcu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименование в палитре команд оставляет курсор в конце." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenovanje u paleti naredbi zadržava kursor na kraju." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التسمية في لوحة الأوامر تبقي المؤشر في النهاية." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Omdøping i kommandopaletten beholder markøren på slutten." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A renomeação na Paleta de Comandos mantém o cursor no final." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การเปลี่ยนชื่อในแถบคำสั่งจะเก็บเคอร์เซอร์ไว้ที่ท้าย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Komut Paleti yeniden adlandırması imleci sonda tutar." + } + } + } + }, + "settings.app.renameSelectsName.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Palette rename starts with all text selected." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドパレットの名称変更ではテキスト全体が選択された状態で始まります。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令面板重命名时全选文本。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "指令面板重新命名時,會先選取所有文字。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "명령어 팔레트의 이름 변경 시 전체 텍스트가 선택됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Umbenennen in der Befehlspalette wird der gesamte Text ausgewählt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar en la paleta de comandos inicia con todo el texto seleccionado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le renommage via la palette de commandes commence avec tout le texte sélectionné." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La rinomina nella Tavolozza comandi inizia con tutto il testo selezionato." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøbning i kommandopaletten starter med al tekst markeret." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmiana nazwy w palecie poleceń rozpoczyna z zaznaczonym całym tekstem." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименование в палитре команд начинается с выделения всего текста." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenovanje u paleti naredbi počinje sa svim odabranim tekstom." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التسمية في لوحة الأوامر تبدأ بتحديد كل النص." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Omdøping i kommandopaletten starter med all tekst valgt." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A renomeação na Paleta de Comandos começa com todo o texto selecionado." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การเปลี่ยนชื่อในแถบคำสั่งจะเลือกข้อความทั้งหมด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Komut Paleti yeniden adlandırması tüm metin seçili olarak başlar." + } + } + } + }, + "settings.app.reorderOnNotification": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reorder on Notification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知時に並べ替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收到通知时重新排序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收到通知時重新排序" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 시 순서 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bei Benachrichtigung neu sortieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reordenar al recibir notificación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réordonner à la notification" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riordina alla notifica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omarranger ved notifikation" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmiana kolejności przy powiadomieniu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перемещение при уведомлении" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prerasporedi pri obavještenju" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة الترتيب عند الإشعار" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Omorganiser ved varsel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reordenar ao Receber Notificação" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จัดเรียงใหม่เมื่อมีการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimde Yeniden Sırala" + } + } + } + }, + "settings.app.reorderOnNotification.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を受け取ったワークスペースを一番上に移動します。ショートカット位置を固定するには無効にしてください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收到通知时将工作区移至顶部。禁用以保持快捷键位置稳定。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收到通知時將工作區移至最上方。停用此選項可維持穩定的快捷鍵位置。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림을 받은 작업 공간을 맨 위로 이동합니다. 단축키 위치를 고정하려면 비활성화하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereiche nach oben verschieben, wenn sie eine Benachrichtigung erhalten. Deaktivieren Sie dies für stabile Tastenkürzel-Positionen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover espacios de trabajo al inicio cuando reciben una notificación. Desactiva para posiciones de atajo estables." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer les espaces de travail en haut lorsqu'ils reçoivent une notification. Désactivez pour des positions de raccourcis stables." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta le aree di lavoro in cima quando ricevono una notifica. Disabilita per posizioni di scorciatoia stabili." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt arbejdsområder til toppen, når de modtager en notifikation. Deaktiver for stabile genvejspositioner." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenoś przestrzenie robocze na górę, gdy otrzymają powiadomienie. Wyłącz, aby zachować stałe pozycje skrótów." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перемещать рабочие пространства наверх при получении уведомления. Отключите для стабильных позиций сочетаний клавиш." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pomjeri radne prostore na vrh kada prime obavještenje. Onemogućite za stabilne pozicije prečica." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل مساحات العمل إلى الأعلى عند تلقي إشعار. قم بالتعطيل للحفاظ على مواضع الاختصارات الثابتة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt arbeidsområder til toppen når de mottar et varsel. Deaktiver for stabile snarveiposisjoner." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Move áreas de trabalho para o topo ao receber uma notificação. Desative para posições de atalho estáveis." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้ายเวิร์กสเปซไปด้านบนเมื่อได้รับการแจ้งเตือน ปิดใช้งานเพื่อให้ตำแหน่งทางลัดคงที่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirim aldıklarında çalışma alanlarını en üste taşı. Sabit kısayol konumları için devre dışı bırakın." + } + } + } + }, + "settings.app.showBranchDirectory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Branch + Directory in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにブランチ+ディレクトリを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在侧边栏显示分支和目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在側邊欄顯示分支與目錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바에 브랜치 + 디렉토리 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Branch + Verzeichnis in Seitenleiste anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar rama + directorio en la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la branche et le répertoire dans la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra branch e directory nella barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis gren + mappe i sidebjælke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż gałąź + katalog na pasku bocznym" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать ветку и каталог в боковой панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži granu i direktorij u bočnoj traci" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الفرع والدليل في الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis gren + katalog i sidepanelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Branch + Diretório na Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงสาขา + ไดเรกทอรีในแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunda Dal + Dizin Göster" + } + } + } + }, + "settings.app.showBranchDirectory.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display the built-in git branch and working-directory row." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "組み込みのgitブランチと作業ディレクトリの行を表示します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示内置的 Git 分支和工作目录行。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示內建的 git 分支及工作目錄列。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 제공 git 브랜치 및 작업 디렉토리 행을 표시합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die integrierte Git-Branch- und Arbeitsverzeichniszeile anzeigen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar la fila integrada de rama git y directorio de trabajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la ligne de branche git et de répertoire de travail intégrée." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Visualizza la riga integrata con il branch git e la directory di lavoro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis den indbyggede git-gren og arbejdsmapperækken." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyświetlaj wbudowany wiersz gałęzi git i katalogu roboczego." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображать встроенную строку ветки git и рабочего каталога." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži ugrađeni red za git granu i radni direktorij." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض صف فرع git المدمج ودليل العمل." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis den innebygde git-grenen og arbeidskatalograden." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Exibir a linha integrada de branch git e diretório de trabalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงแถวสาขา git และไดเรกทอรีทำงานในตัว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yerleşik git dalı ve çalışma dizini satırını göster." + } + } + } + }, + "settings.app.showLog": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Latest Log in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーに最新ログを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在侧边栏显示最新日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在側邊欄顯示最新記錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바에 최신 로그 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Letztes Protokoll in Seitenleiste anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar último registro en la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher le dernier journal dans la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra ultimo log nella barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis seneste log i sidebjælke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż najnowszy dziennik na pasku bocznym" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать последний журнал в боковой панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži najnoviji log u bočnoj traci" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض أحدث سجل في الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis siste logg i sidepanelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Último Log na Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงบันทึกล่าสุดในแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunda Son Günlüğü Göster" + } + } + } + }, + "settings.app.showLog.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display the latest imperative log/status message." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の命令型ログ/ステータスメッセージを表示します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示最新的命令式日志/状态消息。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示最新的命令式記錄或狀態訊息。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신 명령형 로그/상태 메시지를 표시합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die letzte imperative Protokoll-/Statusmeldung anzeigen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar el último mensaje imperativo de registro/estado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher le dernier message de journal/statut impératif." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Visualizza l'ultimo messaggio di log/stato imperativo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis den seneste imperative log/statusmeddelelse." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyświetlaj najnowszy komunikat dziennika/statusu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображать последнее служебное сообщение журнала/статуса." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži najnoviju imperativnu log/statusnu poruku." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض أحدث رسالة سجل/حالة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis den siste imperative logg-/statusmeldingen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Exibir a última mensagem imperativa de log/status." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงข้อความบันทึก/สถานะล่าสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Son zorunlu günlük/durum mesajını göster." + } + } + } + }, + "settings.app.showMetadata": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Custom Metadata in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにカスタムメタデータを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在侧边栏显示自定义元数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在側邊欄顯示自訂中繼資料" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바에 사용자 지정 메타데이터 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Metadaten in Seitenleiste anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar metadatos personalizados en la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les métadonnées personnalisées dans la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra metadati personalizzati nella barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis brugerdefinerede metadata i sidebjælke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż własne metadane na pasku bocznym" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать метаданные в боковой панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži prilagođene metapodatke u bočnoj traci" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض البيانات الوصفية المخصصة في الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis egendefinerte metadata i sidepanelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Metadados Personalizados na Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงข้อมูลเมตาที่กำหนดเองในแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunda Özel Üst Veriyi Göster" + } + } + } + }, + "settings.app.showMetadata.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display custom metadata from report_meta/set_status and report_meta_block." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "report_meta/set_statusおよびreport_meta_blockからのカスタムメタデータを表示します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示来自 report_meta/set_status 和 report_meta_block 的自定义元数据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示來自 report_meta/set_status 和 report_meta_block 的自訂中繼資料。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "report_meta/set_status 및 report_meta_block의 사용자 지정 메타데이터를 표시합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Metadaten von report_meta/set_status und report_meta_block anzeigen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar metadatos personalizados de report_meta/set_status y report_meta_block." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les métadonnées personnalisées de report_meta/set_status et report_meta_block." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Visualizza metadati personalizzati da report_meta/set_status e report_meta_block." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis brugerdefinerede metadata fra report_meta/set_status og report_meta_block." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyświetlaj własne metadane z report_meta/set_status i report_meta_block." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображать пользовательские метаданные из report_meta/set_status и report_meta_block." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži prilagođene metapodatke iz report_meta/set_status i report_meta_block." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض البيانات الوصفية المخصصة من report_meta/set_status و report_meta_block." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis egendefinerte metadata fra report_meta/set_status og report_meta_block." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Exibir metadados personalizados de report_meta/set_status e report_meta_block." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงข้อมูลเมตาที่กำหนดเองจาก report_meta/set_status และ report_meta_block" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "report_meta/set_status ve report_meta_block'tan özel üst veriyi göster." + } + } + } + }, + "settings.app.showPorts": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Listening Ports in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにリスニングポートを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在侧边栏显示监听端口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在側邊欄顯示監聽連接埠" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바에 수신 대기 포트 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lauschende Ports in Seitenleiste anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar puertos en escucha en la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les ports en écoute dans la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra porte in ascolto nella barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis lyttende porte i sidebjælke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż nasłuchujące porty na pasku bocznym" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать прослушиваемые порты в боковой панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži osluškivane portove u bočnoj traci" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض المنافذ النشطة في الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis lyttende porter i sidepanelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Portas em Escuta na Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงพอร์ตที่กำลังรับฟังในแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunda Dinlenen Portları Göster" + } + } + } + }, + "settings.app.showPorts.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display detected listening ports for the active workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティブなワークスペースで検出されたリスニングポートを表示します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示活动工作区检测到的监听端口。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示使用中工作區偵測到的監聽連接埠。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활성 작업 공간에서 감지된 수신 대기 포트를 표시합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erkannte lauschende Ports für den aktiven Arbeitsbereich anzeigen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar los puertos en escucha detectados para el espacio de trabajo activo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les ports en écoute détectés pour l'espace de travail actif." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Visualizza le porte in ascolto rilevate per l'area di lavoro attiva." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis registrerede lyttende porte for det aktive arbejdsområde." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyświetlaj wykryte nasłuchujące porty dla aktywnej przestrzeni roboczej." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображать обнаруженные прослушиваемые порты для активного рабочего пространства." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži otkrivene osluškivane portove za aktivni radni prostor." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض المنافذ المكتشفة لمساحة العمل النشطة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis oppdagede lyttende porter for det aktive arbeidsområdet." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Exibir portas em escuta detectadas para a área de trabalho ativa." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงพอร์ตที่ตรวจพบสำหรับเวิร์กสเปซที่ใช้งานอยู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Etkin çalışma alanı için algılanan dinlenen portları göster." + } + } + } + }, + "settings.app.showProgress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Progress in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーに進捗を表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在侧边栏显示进度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在側邊欄顯示進度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바에 진행 상황 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortschritt in Seitenleiste anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar progreso en la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la progression dans la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra progresso nella barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis fremskridt i sidebjælke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż postęp na pasku bocznym" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать прогресс в боковой панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži napredak u bočnoj traci" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض التقدم في الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis fremdrift i sidepanelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Progresso na Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงความคืบหน้าในแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunda İlerlemeyi Göster" + } + } + } + }, + "settings.app.showProgress.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display the built-in progress bar from set_progress." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "set_progressによる組み込みプログレスバーを表示します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示来自 set_progress 的内置进度条。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示來自 set_progress 的內建進度列。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "set_progress의 기본 제공 진행 표시줄을 표시합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Den integrierten Fortschrittsbalken von set_progress anzeigen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar la barra de progreso integrada de set_progress." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la barre de progression intégrée de set_progress." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Visualizza la barra di progresso integrata da set_progress." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis den indbyggede fremskridtslinje fra set_progress." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyświetlaj wbudowany pasek postępu z set_progress." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображать встроенный индикатор прогресса из set_progress." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži ugrađenu traku napretka iz set_progress." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض شريط التقدم المدمج من set_progress." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis den innebygde fremdriftsindikatoren fra set_progress." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Exibir a barra de progresso integrada de set_progress." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงแถบความคืบหน้าในตัวจาก set_progress" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "set_progress'ten yerleşik ilerleme çubuğunu göster." + } + } + } + }, + "settings.app.showPullRequests": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Pull Requests in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにプルリクエストを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在侧边栏显示拉取请求" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在側邊欄顯示 Pull Request" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바에 Pull Request 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pull Requests in Seitenleiste anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar pull requests en la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les Pull Requests dans la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra Pull Request nella barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis pull requests i sidebjælke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż pull requesty na pasku bocznym" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать запросы на слияние в боковой панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži pull zahtjeve u bočnoj traci" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض طلبات السحب في الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis pull-forespørsler i sidepanelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Pull Requests na Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดง Pull Request ในแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunda Çekme İsteklerini Göster" + } + } + } + }, + "settings.app.showPullRequests.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display review items (PR/MR/etc.) with status, number, and clickable link." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステータス、番号、クリック可能なリンク付きのレビュー項目(PR/MRなど)を表示します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示包含状态、编号和可点击链接的审查项(PR/MR 等)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示審查項目(PR/MR 等),包含狀態、編號和可點擊的連結。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태, 번호 및 클릭 가능한 링크가 있는 리뷰 항목(PR/MR 등)을 표시합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Review-Elemente (PR/MR/etc.) mit Status, Nummer und anklickbarem Link anzeigen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar elementos de revisión (PR/MR/etc.) con estado, número y enlace interactivo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les éléments de revue (PR/MR/etc.) avec le statut, le numéro et un lien cliquable." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Visualizza elementi di revisione (PR/MR/ecc.) con stato, numero e link cliccabile." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis gennemgangselementer (PR/MR osv.) med status, nummer og klikbart link." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyświetlaj elementy przeglądu (PR/MR/itp.) ze statusem, numerem i klikalnym linkiem." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображать элементы проверки (PR/MR и т.д.) со статусом, номером и ссылкой." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži stavke za pregled (PR/MR/itd.) sa statusom, brojem i linkom." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض عناصر المراجعة (طلبات السحب/الدمج/إلخ.) مع الحالة والرقم والرابط القابل للنقر." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis gjennomgangselementer (PR/MR/osv.) med status, nummer og klikkbar lenke." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Exibir itens de revisão (PR/MR/etc.) com status, número e link clicável." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงรายการตรวจสอบ (PR/MR/อื่นๆ) พร้อมสถานะ หมายเลข และลิงก์ที่คลิกได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durum, numara ve tıklanabilir bağlantıyla inceleme öğelerini (PR/MR/vb.) göster." + } + } + } + }, + "settings.app.sidebarBranchLayout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar Branch Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーのブランチレイアウト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "侧边栏分支布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "側邊欄分支版面" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 브랜치 레이아웃" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleisten-Branch-Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disposición de ramas en la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition des branches dans la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Layout branch nella barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sidebjælkens grenlayout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Układ gałęzi na pasku bocznym" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет веток в боковой панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raspored grana u bočnoj traci" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط الفروع في الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Grenoppsett i sidepanelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Layout de Branch na Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เค้าโครงสาขาในแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğu Dal Düzeni" + } + } + } + }, + "settings.app.sidebarBranchLayout.inline": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インライン" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "单行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "行內" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인라인" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einzeilig" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En línea" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En ligne" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In linea" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Inline" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "W linii" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В строку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "U redu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سطري" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Innebygd" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Em Linha" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แบบอินไลน์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Satır İçi" + } + } + } + }, + "settings.app.sidebarBranchLayout.subtitleInline": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inline: all branches share one line." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インライン: すべてのブランチが1行に表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "单行:所有分支共享一行。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "行內:所有分支共用一行。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인라인: 모든 브랜치가 한 줄에 표시됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einzeilig: Alle Branches teilen sich eine Zeile." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En línea: todas las ramas comparten una sola línea." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En ligne : toutes les branches partagent une seule ligne." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In linea: tutti i branch condividono una riga." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Inline: alle grene deler én linje." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "W linii: wszystkie gałęzie w jednym wierszu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В строку: все ветки в одной строке." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "U redu: sve grane dijele jednu liniju." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سطري: جميع الفروع تشترك في سطر واحد." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Innebygd: alle grener deler én linje." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Em Linha: todas as branches compartilham uma linha." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แบบอินไลน์: สาขาทั้งหมดแสดงในบรรทัดเดียว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Satır İçi: tüm dallar tek satırı paylaşır." + } + } + } + }, + "settings.app.sidebarBranchLayout.subtitleVertical": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vertical: each branch appears on its own line." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "縦: 各ブランチがそれぞれの行に表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "垂直:每个分支独占一行。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "垂直:每個分支獨立一行。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세로: 각 브랜치가 별도의 줄에 표시됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vertikal: Jeder Branch erscheint in einer eigenen Zeile." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vertical: cada rama aparece en su propia línea." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vertical : chaque branche apparaît sur sa propre ligne." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Verticale: ogni branch appare sulla propria riga." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lodret: hver gren vises på sin egen linje." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pionowo: każda gałąź w osobnym wierszu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вертикально: каждая ветка на отдельной строке." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vertikalno: svaka grana se pojavljuje u svom redu." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عمودي: كل فرع يظهر في سطر خاص به." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vertikal: hver gren vises på sin egen linje." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Vertical: cada branch aparece em sua própria linha." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แบบแนวตั้ง: แต่ละสาขาแสดงในบรรทัดของตัวเอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dikey: her dal kendi satırında görünür." + } + } + } + }, + "settings.app.sidebarBranchLayout.vertical": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vertical" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "縦" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "垂直" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "垂直" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세로" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vertikal" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vertical" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vertical" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Verticale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lodret" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pionowo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вертикально" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vertikalno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عمودي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vertikal" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Vertical" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แนวตั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dikey" + } + } + } + }, + "settings.app.telemetry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send anonymous telemetry" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "匿名のテレメトリを送信" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发送匿名遥测数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "傳送匿名遙測資料" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "익명 원격 측정 전송" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anonyme Telemetriedaten senden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar telemetría anónima" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer des données de télémétrie anonymes" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invia telemetria anonima" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Send anonym telemetri" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wysyłaj anonimową telemetrię" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправлять анонимную телеметрию" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Šalji anonimnu telemetriju" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إرسال بيانات تحليلية مجهولة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Send anonym telemetri" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Enviar telemetria anônima" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่งข้อมูลการวิเคราะห์แบบไม่ระบุตัวตน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Anonim telemetri gönder" + } + } + } + }, + "settings.app.telemetry.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Share anonymized crash and usage data to help improve cmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxの改善に役立てるため、匿名化されたクラッシュおよび使用状況データを共有します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分享匿名的崩溃和使用数据,帮助改进 cmux。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "分享匿名的當機與使用資料,以協助改善 cmux。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 개선을 위해 익명화된 충돌 및 사용 데이터를 공유합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anonymisierte Absturz- und Nutzungsdaten teilen, um cmux zu verbessern." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Compartir datos anónimos de fallos y uso para ayudar a mejorar cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Partager des données anonymisées de plantage et d'utilisation pour aider à améliorer cmux." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Condividi dati anonimi su arresti anomali e utilizzo per migliorare cmux." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Del anonymiserede nedbrud- og brugsdata for at hjælpe med at forbedre cmux." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Udostępniaj zanonimizowane dane o awariach i użyciu, aby pomóc ulepszyć cmux." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Делиться анонимными данными о сбоях и использовании для улучшения cmux." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dijelite anonimizirane podatke o padovima i korištenju kako biste pomogli u poboljšanju cmux." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مشاركة بيانات الأعطال والاستخدام المجهولة للمساعدة في تحسين cmux." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del anonymiserte krasj- og bruksdata for å bidra til å forbedre cmux." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Compartilhar dados anonimizados de falhas e uso para ajudar a melhorar o cmux." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แชร์ข้อมูลการขัดข้องและการใช้งานแบบไม่ระบุตัวตนเพื่อช่วยปรับปรุง cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux'u iyileştirmek için anonimleştirilmiş çökme ve kullanım verilerini paylaş." + } + } + } + }, + "settings.app.telemetry.subtitleChanged": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change takes effect on next launch." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更は次回起動時に反映されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更改将在下次启动时生效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "變更將在下次啟動時生效。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "변경 사항은 다음 실행 시 적용됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Änderung wird beim nächsten Start wirksam." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El cambio se aplicará en el próximo inicio." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La modification prendra effet au prochain lancement." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La modifica avrà effetto al prossimo avvio." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ændringen træder i kraft ved næste start." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmiana zacznie obowiązywać po ponownym uruchomieniu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменение вступит в силу при следующем запуске." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Promjena stupa na snagu pri sljedećem pokretanju." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يسري التغيير عند التشغيل التالي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Endringen trer i kraft ved neste oppstart." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A alteração terá efeito na próxima inicialização." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การเปลี่ยนแปลงจะมีผลเมื่อเปิดใหม่ครั้งถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Değişiklik bir sonraki başlatmada geçerli olur." + } + } + } + }, + "settings.app.theme": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Theme" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テーマ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "主題" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "테마" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Design" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Thème" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tema" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tema" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Motyw" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тема" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tema" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المظهر" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tema" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tema" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ธีม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tema" + } + } + } + }, + "settings.app.warnBeforeQuit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warn Before Quit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了前に警告" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出前警告" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結束前警告" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료 전 경고" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vor dem Beenden warnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Advertir antes de salir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Avertir avant de quitter" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avvisa prima di uscire" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Advar før afslutning" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ostrzegaj przed zamknięciem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предупреждать перед выходом" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upozori prije zatvaranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحذير قبل الإنهاء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Advar før avslutning" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Avisar Antes de Encerrar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เตือนก่อนออก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çıkmadan Önce Uyar" + } + } + } + }, + "settings.app.warnBeforeQuit.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q quits immediately without confirmation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Qで確認なしにすぐ終了します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q 直接退出,无需确认。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q 會立即結束,不顯示確認提示。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q를 누르면 확인 없이 즉시 종료됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q beendet sofort ohne Bestätigung." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q sale inmediatamente sin confirmación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q quitte immédiatement sans confirmation." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q esce immediatamente senza conferma." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q afslutter øjeblikkeligt uden bekræftelse." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q zamyka natychmiast bez potwierdzenia." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q завершает приложение немедленно без подтверждения." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q odmah zatvara bez potvrde." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q ينهي فورًا بدون تأكيد." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q avslutter umiddelbart uten bekreftelse." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q encerra imediatamente sem confirmação." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q ออกทันทีโดยไม่มีการยืนยัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q onay olmadan hemen çıkar." + } + } + } + }, + "settings.app.warnBeforeQuit.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show a confirmation before quitting with Cmd+Q." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Qで終了する前に確認を表示します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 Cmd+Q 退出前显示确认对话框。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用 Cmd+Q 結束前顯示確認提示。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q로 종료하기 전에 확인 대화상자를 표시합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vor dem Beenden mit Cmd+Q eine Bestätigung anzeigen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar una confirmación antes de salir con Cmd+Q." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher une confirmation avant de quitter avec Cmd+Q." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra una conferma prima di uscire con Cmd+Q." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis en bekræftelse, før du afslutter med Cmd+Q." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż potwierdzenie przed zamknięciem za pomocą Cmd+Q." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать подтверждение перед выходом по Cmd+Q." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži potvrdu prije zatvaranja sa Cmd+Q." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض تأكيد قبل الإنهاء بـ Cmd+Q." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis en bekreftelse før avslutning med Cmd+Q." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar uma confirmação antes de encerrar com Cmd+Q." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงการยืนยันก่อนออกด้วย Cmd+Q" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Q ile çıkmadan önce onay göster." + } + } + } + }, + "settings.notifications.sound.custom.choose.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択..." + } + } + } + }, + "settings.notifications.sound.custom.choose.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択" + } + } + } + }, + "settings.notifications.sound.custom.choose.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose Notification Sound" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知サウンドを選択" + } + } + } + }, + "settings.notifications.sound.custom.clear.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリア" + } + } + } + }, + "settings.notifications.sound.custom.error.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Notification Sound Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム通知サウンドのエラー" + } + } + } + }, + "settings.notifications.sound.custom.file.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No file selected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル未選択" + } + } + } + }, + "settings.notifications.sound.custom.status.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a custom audio file first." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "先にカスタム音声ファイルを選択してください。" + } + } + } + }, + "settings.notifications.sound.custom.status.missingExtensionPrefix": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File needs an extension: " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡張子が必要です: " + } + } + } + }, + "settings.notifications.sound.custom.status.missingFilePrefix": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File not found: " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルが見つかりません: " + } + } + } + }, + "settings.notifications.sound.custom.status.prepareFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not prepare this file for notifications. Try WAV, AIFF, or CAF." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知用にこのファイルを準備できませんでした。WAV、AIFF、またはCAFを試してください。" + } + } + } + }, + "settings.notifications.sound.custom.status.ready": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready for notifications." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知用の準備ができました。" + } + } + } + }, + "settings.notifications.sound.custom.status.readyConverted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prepared for notifications (converted to CAF)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知用に準備しました(CAFに変換)。" + } + } + } + }, + "settings.notifications.sound.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sound played when a notification arrives." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を受信したときに再生するサウンドです。" + } + } + } + }, + "settings.notifications.sound.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notification Sound" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知サウンド" + } + } + } + }, + "settings.automation.claudeCode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude Code Integration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code連携" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Claude Code 集成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Claude Code 整合" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude Code 연동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Claude Code-Integration" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Integración con Claude Code" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intégration Claude Code" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Integrazione Claude Code" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Claude Code-integration" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Integracja z Claude Code" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Интеграция с Claude Code" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Claude Code integracija" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تكامل Claude Code" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Claude Code-integrering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Integração com Claude Code" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การผสานรวม Claude Code" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude Code Entegrasyonu" + } + } + } + }, + "settings.automation.claudeCode.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When enabled, cmux wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効にすると、cmuxはclaudeコマンドをラップしてセッション追跡と通知フックを挿入します。Claude Codeのフックを自分で管理する場合は無効にしてください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用后,cmux 会包装 claude 命令以注入会话跟踪和通知钩子。如果您希望自行管理 Claude Code 钩子,请禁用此选项。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用後,cmux 會包裝 claude 指令以注入工作階段追蹤和通知掛鉤。如果您偏好自行管理 Claude Code 掛鉤,請停用此選項。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활성화하면 cmux가 claude 명령을 래핑하여 세션 추적 및 알림 훅을 삽입합니다. Claude Code 훅을 직접 관리하려면 비활성화하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn aktiviert, umhüllt cmux den claude-Befehl, um Sitzungsverfolgung und Benachrichtigungs-Hooks einzufügen. Deaktivieren Sie dies, wenn Sie Claude Code-Hooks selbst verwalten möchten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuando está activado, cmux envuelve el comando claude para inyectar seguimiento de sesión y hooks de notificación. Desactiva si prefieres gestionar los hooks de Claude Code tú mismo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lorsque cette option est activée, cmux encapsule la commande claude pour injecter le suivi de session et les hooks de notification. Désactivez si vous préférez gérer les hooks Claude Code vous-même." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quando abilitato, cmux avvolge il comando claude per iniettare il tracciamento della sessione e gli hook di notifica. Disabilita se preferisci gestire gli hook di Claude Code manualmente." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Når aktiveret, wrapper cmux claude-kommandoen for at injicere sessionssporing og notifikationshooks. Deaktiver, hvis du foretrækker at administrere Claude Code-hooks selv." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po włączeniu cmux opakowuje polecenie claude, aby wstrzyknąć śledzenie sesji i hooki powiadomień. Wyłącz, jeśli wolisz samodzielnie zarządzać hookami Claude Code." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При включении cmux оборачивает команду claude для отслеживания сеансов и уведомлений. Отключите, если предпочитаете управлять хуками Claude Code самостоятельно." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kada je omogućeno, cmux omotava naredbu claude kako bi ubacio praćenje sesije i zakačke za obavještenja. Onemogućite ako preferirate sami upravljati Claude Code zakačkama." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عند التفعيل، يغلّف cmux أمر claude لحقن تتبع الجلسة وخطافات الإشعارات. قم بالتعطيل إذا كنت تفضل إدارة خطافات Claude Code بنفسك." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når aktivert, pakker cmux inn claude-kommandoen for å injisere sesjonssporing og varslingsmekanismer. Deaktiver hvis du foretrekker å håndtere Claude Code-mekanismer selv." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Quando ativado, o cmux encapsula o comando claude para injetar rastreamento de sessão e hooks de notificação. Desative se preferir gerenciar os hooks do Claude Code você mesmo." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เมื่อเปิดใช้งาน cmux จะห่อคำสั่ง claude เพื่อเพิ่มการติดตามเซสชันและตะขอการแจ้งเตือน ปิดใช้งานหากคุณต้องการจัดการตะขอ Claude Code ด้วยตัวเอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Etkinleştirildiğinde, cmux oturum izleme ve bildirim kancaları eklemek için claude komutunu sarar. Claude Code kancalarını kendiniz yönetmeyi tercih ediyorsanız devre dışı bırakın." + } + } + } + }, + "settings.automation.claudeCode.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude Code runs without cmux integration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Codeはcmux連携なしで実行されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Claude Code 在没有 cmux 集成的情况下运行。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Claude Code 在不使用 cmux 整合的情況下運行。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude Code가 cmux 연동 없이 실행됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Claude Code wird ohne cmux-Integration ausgeführt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Claude Code se ejecuta sin integración con cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Claude Code s'exécute sans intégration cmux." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Claude Code viene eseguito senza integrazione cmux." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Claude Code kører uden cmux-integration." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Claude Code działa bez integracji z cmux." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Claude Code работает без интеграции с cmux." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Claude Code radi bez cmux integracije." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعمل Claude Code بدون تكامل cmux." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Claude Code kjører uten cmux-integrering." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O Claude Code é executado sem integração com o cmux." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Claude Code ทำงานโดยไม่มีการผสานรวม cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude Code cmux entegrasyonu olmadan çalışır." + } + } + } + }, + "settings.automation.claudeCode.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar shows Claude session status and notifications." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにClaudeセッションのステータスと通知が表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "侧边栏显示 Claude 会话状态和通知。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "側邊欄顯示 Claude 工作階段狀態和通知。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바에 Claude 세션 상태와 알림이 표시됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Seitenleiste zeigt den Claude-Sitzungsstatus und Benachrichtigungen an." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La barra lateral muestra el estado de la sesión de Claude y las notificaciones." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La barre latérale affiche le statut de la session Claude et les notifications." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La barra laterale mostra lo stato della sessione Claude e le notifiche." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sidebjælken viser Claude-sessionsstatus og notifikationer." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pasek boczny pokazuje status sesji Claude i powiadomienia." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Боковая панель показывает статус сеанса Claude и уведомления." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Bočna traka prikazuje status Claude sesije i obavještenja." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض الشريط الجانبي حالة جلسة Claude والإشعارات." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sidepanelet viser Claude-sesjonsstatus og varsler." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A barra lateral mostra o status da sessão do Claude e notificações." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แถบด้านข้างแสดงสถานะเซสชัน Claude และการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar çubuğu Claude oturum durumunu ve bildirimlerini gösterir." + } + } + } + }, + "settings.automation.openAccess.dialog.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Annulla" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Annuller" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Anuluj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отменить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkaži" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avbryt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Vazgeç" + } + } + } + }, + "settings.automation.openAccess.dialog.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Full Open Access" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルオープンアクセスを有効にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用完全开放访问" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用完全開放存取" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 개방 접근 활성화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollständigen offenen Zugriff aktivieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar acceso abierto completo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer l'accès ouvert complet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Abilita accesso aperto completo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Aktiver fuld åben adgang" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Włącz pełny otwarty dostęp" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить полный открытый доступ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Omogući potpuni otvoreni pristup" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفعيل الوصول المفتوح الكامل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktiver full åpen tilgang" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ativar Acesso Aberto Total" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดใช้งานการเข้าถึงเปิดแบบเต็ม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tam Açık Erişimi Etkinleştir" + } + } + } + }, + "settings.automation.openAccess.dialog.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これにより、祖先プロセスチェックとパスワードチェックが無効になり、すべてのローカルユーザーにソケットが公開されます。リスクを理解した上で有効にしてください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将禁用祖先和密码检查,并向所有本地用户开放套接字。请确保您了解其中的风险后再启用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將停用來源驗證和密碼檢查,並將 Socket 開放給所有本機使用者。請確認您了解風險後再啟用。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 설정은 출처 확인 및 비밀번호 검사를 비활성화하고 모든 로컬 사용자에게 소켓을 개방합니다. 위험을 이해한 경우에만 활성화하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dies deaktiviert Abstammungs- und Passwortprüfungen und öffnet den Socket für alle lokalen Benutzer. Aktivieren Sie dies nur, wenn Sie das Risiko verstehen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto desactiva las verificaciones de ascendencia y contraseña, y abre el socket a todos los usuarios locales. Activa solo si comprendes el riesgo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela désactive les vérifications de parenté et de mot de passe et ouvre le socket à tous les utilisateurs locaux. N'activez que si vous comprenez les risques." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa operazione disabilita i controlli di discendenza e password e apre il socket a tutti gli utenti locali. Abilita solo se comprendi il rischio." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dette deaktiverer herkomst- og adgangskodekontrol og åbner socketen for alle lokale brugere. Aktiver kun, når du forstår risikoen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "To wyłącza sprawdzanie pochodzenia i hasła oraz otwiera gniazdo dla wszystkich lokalnych użytkowników. Włączaj tylko wtedy, gdy rozumiesz ryzyko." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это отключает проверку происхождения и пароля и открывает сокет для всех локальных пользователей. Включайте только если понимаете риски." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo onemogućava provjere porijekla i lozinke i otvara utičnicu svim lokalnim korisnicima. Omogućite samo kada razumijete rizik." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يؤدي هذا إلى تعطيل فحوصات النسب وكلمة المرور ويفتح المقبس لجميع المستخدمين المحليين. قم بالتفعيل فقط عندما تفهم المخاطر." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette deaktiverer opphavs- og passordkontroller og åpner socketen for alle lokale brukere. Aktiver bare når du forstår risikoen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isto desativa verificações de ancestralidade e senha e abre o socket para todos os usuários locais. Ative somente quando entender os riscos." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การดำเนินการนี้จะปิดใช้งานการตรวจสอบสายสืบทอดและรหัสผ่าน และเปิดซ็อกเก็ตให้ผู้ใช้ในเครื่องทุกคน เปิดใช้งานเฉพาะเมื่อคุณเข้าใจความเสี่ยง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu, soy ve parola denetimlerini devre dışı bırakır ve soketi tüm yerel kullanıcılara açar. Yalnızca riski anladığınızda etkinleştirin." + } + } + } + }, + "settings.automation.openAccess.dialog.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable full open access?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルオープンアクセスを有効にしますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用完全开放访问?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要啟用完全開放存取嗎?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 개방 접근을 활성화하시겠습니까?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollständigen offenen Zugriff aktivieren?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Activar acceso abierto completo?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer l'accès ouvert complet ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Abilitare l'accesso aperto completo?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Aktiver fuld åben adgang?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Włączyć pełny otwarty dostęp?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить полный открытый доступ?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Omogućiti potpuni otvoreni pristup?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفعيل الوصول المفتوح الكامل؟" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktivere full åpen tilgang?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ativar acesso aberto total?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดใช้งานการเข้าถึงเปิดแบบเต็มหรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tam açık erişim etkinleştirilsin mi?" + } + } + } + }, + "settings.automation.openAccessWarning": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "警告: フルオープンアクセスにすると、このMac上の制御ソケットが誰でも読み書き可能になり、認証チェックが無効になります。ローカルデバッグ用途にのみ使用してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "警告:完全开放访问将使控制套接字在此 Mac 上对所有用户可读/可写,并禁用身份验证检查。仅用于本地调试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "警告:完全開放存取會讓控制 Socket 在此 Mac 上可被所有人讀寫,並停用驗證檢查。僅供本機除錯使用。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "경고: 전체 개방 접근은 이 Mac에서 제어 소켓을 누구나 읽고 쓸 수 있게 하며 인증 검사를 비활성화합니다. 로컬 디버깅 전용으로만 사용하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warnung: Vollständiger offener Zugriff macht den Steuerungs-Socket auf diesem Mac für alle les- und schreibbar und deaktiviert Authentifizierungsprüfungen. Nur für lokales Debugging verwenden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Advertencia: El acceso abierto completo hace que el socket de control sea legible/escribible para todos en este Mac y desactiva las verificaciones de autenticación. Usa solo para depuración local." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Attention : l'accès ouvert complet rend le socket de contrôle lisible/inscriptible par tous sur ce Mac et désactive les vérifications d'authentification. À utiliser uniquement pour le débogage local." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attenzione: l'accesso aperto completo rende il socket di controllo leggibile/scrivibile da tutti su questo Mac e disabilita i controlli di autenticazione. Usa solo per il debug locale." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Advarsel: Fuld åben adgang gør kontrolsocketen læsbar/skrivbar for alle på denne Mac og deaktiverer autentifikationskontrol. Brug kun til lokal fejlsøgning." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ostrzeżenie: Pełny otwarty dostęp czyni gniazdo sterujące odczytywalnym/zapisywalnym dla wszystkich na tym Macu i wyłącza sprawdzanie uwierzytelniania. Używaj tylko do lokalnego debugowania." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внимание: полный открытый доступ делает управляющий сокет доступным для чтения и записи всем пользователям на этом Mac и отключает проверку авторизации. Используйте только для локальной отладки." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upozorenje: Potpuni otvoreni pristup čini kontrolnu utičnicu čitljivom/zapisivom za sve na ovom Macu i onemogućava provjere autentikacije. Koristite samo za lokalno debugiranje." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحذير: الوصول المفتوح الكامل يجعل مقبس التحكم قابلاً للقراءة والكتابة من الجميع على هذا الـ Mac ويعطل فحوصات المصادقة. استخدم فقط لأغراض التصحيح المحلي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Advarsel: Full åpen tilgang gjør kontrollsocketen lese-/skrivbar for alle på denne Mac-en og deaktiverer autentiseringskontroller. Bruk kun for lokal feilsøking." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aviso: O acesso aberto total torna o socket de controle legível/gravável por todos neste Mac e desativa verificações de autenticação. Use apenas para depuração local." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำเตือน: การเข้าถึงเปิดแบบเต็มทำให้ซ็อกเก็ตควบคุมอ่าน/เขียนได้จากทุกผู้ใช้บน Mac นี้และปิดใช้งานการตรวจสอบสิทธิ์ ใช้สำหรับการดีบักในเครื่องเท่านั้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uyarı: Tam açık erişim, kontrol soketini bu Mac'te herkes tarafından okunabilir/yazılabilir yapar ve kimlik doğrulama denetimlerini devre dışı bırakır. Yalnızca yerel hata ayıklama için kullanın." + } + } + } + }, + "settings.automation.port.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "各ワークスペースにはCMUX_PORTとCMUX_PORT_END環境変数で専用のポート範囲が割り当てられます。新しいターミナルはこれらの値を継承します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每个工作区会获得 CMUX_PORT 和 CMUX_PORT_END 环境变量,包含专用的端口范围。新终端会继承这些值。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每個工作區都會取得包含專用連接埠範圍的 CMUX_PORT 和 CMUX_PORT_END 環境變數。新終端機會繼承這些值。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "각 작업 공간에 전용 포트 범위가 있는 CMUX_PORT 및 CMUX_PORT_END 환경 변수가 할당됩니다. 새 터미널은 이 값을 상속합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jeder Arbeitsbereich erhält CMUX_PORT- und CMUX_PORT_END-Umgebungsvariablen mit einem dedizierten Portbereich. Neue Terminals erben diese Werte." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cada espacio de trabajo recibe las variables de entorno CMUX_PORT y CMUX_PORT_END con un rango de puertos dedicado. Los nuevos terminales heredan estos valores." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chaque espace de travail reçoit les variables d'environnement CMUX_PORT et CMUX_PORT_END avec une plage de ports dédiée. Les nouveaux terminaux héritent de ces valeurs." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ogni area di lavoro riceve le variabili d'ambiente CMUX_PORT e CMUX_PORT_END con un intervallo di porte dedicato. I nuovi terminali ereditano questi valori." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hvert arbejdsområde får CMUX_PORT og CMUX_PORT_END miljøvariabler med et dedikeret portområde. Nye terminaler arver disse værdier." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Każda przestrzeń robocza otrzymuje zmienne środowiskowe CMUX_PORT i CMUX_PORT_END z dedykowanym zakresem portów. Nowe terminale dziedziczą te wartości." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Каждое рабочее пространство получает переменные окружения CMUX_PORT и CMUX_PORT_END с выделенным диапазоном портов. Новые терминалы наследуют эти значения." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Svaki radni prostor dobija CMUX_PORT i CMUX_PORT_END varijable okruženja sa dodijeljenim rasponom portova. Novi terminali nasljeđuju ove vrijednosti." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحصل كل مساحة عمل على متغيرات البيئة CMUX_PORT و CMUX_PORT_END مع نطاق منافذ مخصص. ترث الطرفيات الجديدة هذه القيم." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hvert arbeidsområde får CMUX_PORT- og CMUX_PORT_END-miljøvariabler med et dedikert portområde. Nye terminaler arver disse verdiene." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cada área de trabalho recebe as variáveis de ambiente CMUX_PORT e CMUX_PORT_END com uma faixa de portas dedicada. Novos terminais herdam esses valores." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แต่ละเวิร์กสเปซจะได้รับตัวแปรสภาพแวดล้อม CMUX_PORT และ CMUX_PORT_END พร้อมช่วงพอร์ตเฉพาะ เทอร์มินัลใหม่จะสืบทอดค่าเหล่านี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Her çalışma alanı ayrılmış bir port aralığıyla CMUX_PORT ve CMUX_PORT_END ortam değişkenlerini alır. Yeni terminaller bu değerleri devralır." + } + } + } + }, + "settings.automation.portBase": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Port Base" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポートベース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "起始端口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連接埠起始值" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포트 기준값" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Port-Basis" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Puerto base" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Port de base" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Porta base" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Portbase" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Port bazowy" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Базовый порт" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Početni port" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المنفذ الأساسي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Portbase" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Porta Base" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พอร์ตเริ่มต้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Port Tabanı" + } + } + } + }, + "settings.automation.portBase.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting port for CMUX_PORT env var." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CMUX_PORT環境変数の開始ポート。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CMUX_PORT 环境变量的起始端口。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "CMUX_PORT 環境變數的起始連接埠。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "CMUX_PORT 환경 변수의 시작 포트." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startport für die CMUX_PORT-Umgebungsvariable." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Puerto inicial para la variable de entorno CMUX_PORT." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Port de départ pour la variable d'environnement CMUX_PORT." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Porta iniziale per la variabile d'ambiente CMUX_PORT." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Startport for CMUX_PORT-miljøvariabel." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Port początkowy dla zmiennej środowiskowej CMUX_PORT." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начальный порт для переменной окружения CMUX_PORT." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Početni port za CMUX_PORT varijablu okruženja." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المنفذ الابتدائي لمتغير البيئة CMUX_PORT." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Startport for CMUX_PORT-miljøvariabelen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Porta inicial para a variável de ambiente CMUX_PORT." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พอร์ตเริ่มต้นสำหรับตัวแปรสภาพแวดล้อม CMUX_PORT" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "CMUX_PORT ortam değişkeni için başlangıç portu." + } + } + } + }, + "settings.automation.portRange": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Port Range Size" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポート範囲サイズ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "端口范围大小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連接埠範圍大小" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포트 범위 크기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Portbereichsgröße" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tamaño del rango de puertos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille de la plage de ports" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dimensione intervallo porte" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Portområdestørrelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozmiar zakresu portów" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Размер диапазона портов" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Veličina raspona portova" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حجم نطاق المنافذ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Portområdestørrelse" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tamanho da Faixa de Portas" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดช่วงพอร์ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Port Aralığı Boyutu" + } + } + } + }, + "settings.automation.portRange.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Number of ports per workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースあたりのポート数。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每个工作区的端口数量。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每個工作區的連接埠數量。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간당 포트 수." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl der Ports pro Arbeitsbereich." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Número de puertos por espacio de trabajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nombre de ports par espace de travail." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Numero di porte per area di lavoro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Antal porte pr. arbejdsområde." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Liczba portów na przestrzeń roboczą." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Количество портов на рабочее пространство." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Broj portova po radnom prostoru." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عدد المنافذ لكل مساحة عمل." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Antall porter per arbeidsområde." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Número de portas por área de trabalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จำนวนพอร์ตต่อเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı başına port sayısı." + } + } + } + }, + "settings.automation.socketMode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Socket Control Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソケット制御モード" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "套接字控制模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Socket 控制模式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소켓 제어 모드" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Socket-Steuerungsmodus" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Modo de control del socket" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode de contrôle du socket" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Modalità controllo socket" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Socket-kontroltilstand" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tryb sterowania gniazdem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим управления сокетом" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Režim kontrolne utičnice" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وضع التحكم بالمقبس" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Socket-kontrollmodus" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Modo de Controle do Socket" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหมดควบคุมซ็อกเก็ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Soket Kontrol Modu" + } + } + } + }, + "settings.automation.socketMode.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プログラムによる制御のためのローカルUnix socketへのアクセスを制御します。脅威モデルに合ったモードを選択してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制本地 Unix 套接字的访问权限,用于程序化控制。请选择与您的安全需求匹配的模式。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "控制本機 Unix Socket 的程式化控制存取。選擇符合您安全需求的模式。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로그래밍 방식 제어를 위한 로컬 Unix 소켓 접근을 제어합니다. 보안 모델에 맞는 모드를 선택하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steuert den Zugriff auf den lokalen Unix-Socket für programmatische Steuerung. Wählen Sie einen Modus, der Ihrem Bedrohungsmodell entspricht." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Controla el acceso al socket Unix local para control programático. Elige un modo que coincida con tu modelo de amenazas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôle l'accès au socket Unix local pour le contrôle programmatique. Choisissez un mode adapté à votre modèle de menace." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Controlla l'accesso al socket Unix locale per il controllo programmatico. Scegli una modalità adeguata al tuo modello di rischio." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Styrer adgangen til den lokale Unix-socket til programmatisk kontrol. Vælg en tilstand, der passer til din trusselsmodel." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kontroluje dostęp do lokalnego gniazda Unix do programowego sterowania. Wybierz tryb odpowiadający Twojemu modelowi zagrożeń." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляет доступом к локальному Unix-сокету для программного управления. Выберите режим, соответствующий вашей модели угроз." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kontroliše pristup lokalnoj Unix utičnici za programsku kontrolu. Odaberite režim koji odgovara vašem modelu prijetnji." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتحكم بالوصول إلى مقبس Unix المحلي للتحكم البرمجي. اختر الوضع الذي يتناسب مع نموذج التهديد الخاص بك." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Styrer tilgang til den lokale Unix-socketen for programmatisk kontroll. Velg en modus som passer til din trusselmodell." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Controla o acesso ao socket Unix local para controle programático. Escolha um modo que corresponda ao seu modelo de ameaça." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ควบคุมการเข้าถึงซ็อกเก็ต Unix ในเครื่องสำหรับการควบคุมแบบโปรแกรม เลือกโหมดที่ตรงกับรูปแบบภัยคุกคามของคุณ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Programatik kontrol için yerel Unix soketine erişimi kontrol eder. Tehdit modelinize uyan bir mod seçin." + } + } + } + }, + "settings.automation.socketOverrides.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーバーライド: CMUX_SOCKET_ENABLE、CMUX_SOCKET_MODE、CMUX_SOCKET_PATH(stable/nightlyビルドではCMUX_ALLOW_SOCKET_OVERRIDE=1を設定)。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "覆盖项:CMUX_SOCKET_ENABLE、CMUX_SOCKET_MODE 和 CMUX_SOCKET_PATH(对稳定版/每日构建版需设置 CMUX_ALLOW_SOCKET_OVERRIDE=1)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "覆寫設定:CMUX_SOCKET_ENABLE、CMUX_SOCKET_MODE 和 CMUX_SOCKET_PATH(穩定版/每夜版需設定 CMUX_ALLOW_SOCKET_OVERRIDE=1)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재정의: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, CMUX_SOCKET_PATH (안정/나이틀리 빌드의 경우 CMUX_ALLOW_SOCKET_OVERRIDE=1 설정)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überschreibungen: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE und CMUX_SOCKET_PATH (setzen Sie CMUX_ALLOW_SOCKET_OVERRIDE=1 für Stable-/Nightly-Builds)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sobreescrituras: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE y CMUX_SOCKET_PATH (establece CMUX_ALLOW_SOCKET_OVERRIDE=1 para compilaciones estables/nightly)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remplacements : CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE et CMUX_SOCKET_PATH (définissez CMUX_ALLOW_SOCKET_OVERRIDE=1 pour les builds stable/nightly)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Override: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE e CMUX_SOCKET_PATH (imposta CMUX_ALLOW_SOCKET_OVERRIDE=1 per le build stable/nightly)." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilsidesættelser: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE og CMUX_SOCKET_PATH (sæt CMUX_ALLOW_SOCKET_OVERRIDE=1 for stabile/nightly builds)." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nadpisania: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE i CMUX_SOCKET_PATH (ustaw CMUX_ALLOW_SOCKET_OVERRIDE=1 dla wersji stabilnych/nightly)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переопределения: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE и CMUX_SOCKET_PATH (установите CMUX_ALLOW_SOCKET_OVERRIDE=1 для стабильных/ночных сборок)." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Premošćenja: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE i CMUX_SOCKET_PATH (postavite CMUX_ALLOW_SOCKET_OVERRIDE=1 za stabilne/nightly verzije)." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التجاوزات: CMUX_SOCKET_ENABLE و CMUX_SOCKET_MODE و CMUX_SOCKET_PATH (اضبط CMUX_ALLOW_SOCKET_OVERRIDE=1 لبناءات stable/nightly)." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Overstyringer: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE og CMUX_SOCKET_PATH (sett CMUX_ALLOW_SOCKET_OVERRIDE=1 for stabile/nattlige bygg)." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Substituições: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE e CMUX_SOCKET_PATH (defina CMUX_ALLOW_SOCKET_OVERRIDE=1 para builds stable/nightly)." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแทนที่: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE และ CMUX_SOCKET_PATH (ตั้ง CMUX_ALLOW_SOCKET_OVERRIDE=1 สำหรับบิลด์ stable/nightly)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçersiz kılmalar: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE ve CMUX_SOCKET_PATH (kararlı/gece derlemeleri için CMUX_ALLOW_SOCKET_OVERRIDE=1 ayarlayın)." + } + } + } + }, + "settings.automation.socketPassword": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Socket Password" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソケットパスワード" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "套接字密码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Socket 密碼" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소켓 비밀번호" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Socket-Passwort" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraseña del socket" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mot de passe du socket" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Password socket" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Socket-adgangskode" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Hasło gniazda" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пароль сокета" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Lozinka utičnice" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "كلمة مرور المقبس" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Socket-passord" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Senha do Socket" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รหัสผ่านซ็อกเก็ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Soket Parolası" + } + } + } + }, + "settings.automation.socketPassword.change": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更改" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "變更" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ändern" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Modifica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skift" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Promijeni" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تغيير" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Endre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alterar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Değiştir" + } + } + } + }, + "settings.automation.socketPassword.clear": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Temizle" + } + } + } + }, + "settings.automation.socketPassword.clearFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to clear password (%@)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワードのクリアに失敗しました(%@)。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除密码失败 (%@)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除密碼失敗(%@)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호 지우기 실패 (%@)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort konnte nicht gelöscht werden (%@)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo borrar la contraseña (%@)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'effacer le mot de passe (%@)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile cancellare la password (%@)." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke rydde adgangskode (%@)." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się wyczyścić hasła (%@)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось очистить пароль (%@)." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije uspjelo brisanje lozinke (%@)." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل مسح كلمة المرور (%@)." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke fjerne passord (%@)." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao limpar a senha (%@)." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถล้างรหัสผ่านได้ (%@)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Parola temizlenemedi (%@)." + } + } + } + }, + "settings.automation.socketPassword.cleared": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password cleared." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワードをクリアしました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密码已清除。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "密碼已清除。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호가 지워졌습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort gelöscht." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraseña borrada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mot de passe effacé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Password cancellata." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Adgangskode ryddet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Hasło wyczyszczone." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пароль очищен." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Lozinka obrisana." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم مسح كلمة المرور." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Passord fjernet." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Senha removida." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างรหัสผ่านแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Parola temizlendi." + } + } + } + }, + "settings.automation.socketPassword.enterFirst": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a password first." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まずパスワードを入力してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先输入密码。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請先輸入密碼。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "먼저 비밀번호를 입력하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie zuerst ein Passwort ein." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce una contraseña primero." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez d'abord un mot de passe." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci prima una password." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indtast en adgangskode først." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Najpierw wprowadź hasło." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сначала введите пароль." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prvo unesite lozinku." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل كلمة مرور أولاً." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skriv inn et passord først." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Insira uma senha primeiro." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป้อนรหัสผ่านก่อน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önce bir parola girin." + } + } + } + }, + "settings.automation.socketPassword.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワード" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "密碼" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mot de passe" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Adgangskode" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Hasło" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пароль" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Lozinka" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "كلمة المرور" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Passord" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Senha" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รหัสผ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Parola" + } + } + } + }, + "settings.automation.socketPassword.saveFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to save password (%@)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワードの保存に失敗しました(%@)。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存密码失败 (%@)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存密碼失敗(%@)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호 저장 실패 (%@)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort konnte nicht gespeichert werden (%@)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo guardar la contraseña (%@)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'enregistrer le mot de passe (%@)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile salvare la password (%@)." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke gemme adgangskode (%@)." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się zapisać hasła (%@)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось сохранить пароль (%@)." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije uspjelo spremanje lozinke (%@)." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل حفظ كلمة المرور (%@)." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke lagre passord (%@)." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao salvar a senha (%@)." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถบันทึกรหัสผ่านได้ (%@)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Parola kaydedilemedi (%@)." + } + } + } + }, + "settings.automation.socketPassword.saved": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password saved." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワードを保存しました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密码已保存。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "密碼已儲存。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호가 저장되었습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort gespeichert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraseña guardada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mot de passe enregistré." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Password salvata." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Adgangskode gemt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Hasło zapisane." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пароль сохранен." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Lozinka spremljena." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم حفظ كلمة المرور." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Passord lagret." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Senha salva." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บันทึกรหัสผ่านแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Parola kaydedildi." + } + } + } + }, + "settings.automation.socketPassword.set": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Festlegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Establecer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Définir" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Imposta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Angiv" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ustaw" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Postavi" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعيين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Angi" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Definir" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตั้งค่า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarla" + } + } + } + }, + "settings.automation.socketPassword.subtitleSet": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stored in Application Support." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Application Supportに保存済み。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "存储在应用程序支持目录中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存於 Application Support。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Application Support에 저장됨." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gespeichert in Application Support." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Almacenada en Application Support." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Stocké dans Application Support." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Memorizzata nel Supporto Applicazioni." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gemt i Application Support." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przechowywane w Application Support." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хранится в Application Support." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pohranjena u Application Support." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مخزنة في Application Support." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lagret i Application Support." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Armazenada em Application Support." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จัดเก็บใน Application Support" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uygulama Desteği klasöründe saklanır." + } + } + } + }, + "settings.automation.socketPassword.subtitleUnset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No password set. External clients will be blocked until one is configured." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワードが設定されていません。設定するまで外部クライアントはブロックされます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未设置密码。外部客户端将被阻止,直到配置密码为止。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未設定密碼。外部用戶端將被封鎖,直到設定密碼為止。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호가 설정되지 않았습니다. 비밀번호를 구성하기 전까지 외부 클라이언트가 차단됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Passwort festgelegt. Externe Clients werden blockiert, bis eines konfiguriert ist." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se ha establecido contraseña. Los clientes externos serán bloqueados hasta que se configure una." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun mot de passe défini. Les clients externes seront bloqués tant qu'un mot de passe n'aura pas été configuré." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna password impostata. I client esterni verranno bloccati finché non ne viene configurata una." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen adgangskode angivet. Eksterne klienter blokeres, indtil en er konfigureret." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Hasło nie jest ustawione. Klienty zewnętrzne będą blokowane, dopóki nie zostanie skonfigurowane." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пароль не установлен. Внешние клиенты будут заблокированы, пока он не будет настроен." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Lozinka nije postavljena. Vanjski klijenti će biti blokirani dok se ne konfiguriše." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم تعيين كلمة مرور. سيتم حظر العملاء الخارجيين حتى يتم تكوين واحدة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen passord angitt. Eksterne klienter vil bli blokkert inntil et passord er konfigurert." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma senha definida. Clientes externos serão bloqueados até que uma seja configurada." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่ได้ตั้งรหัสผ่าน ไคลเอ็นต์ภายนอกจะถูกบล็อกจนกว่าจะกำหนดค่า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Parola ayarlanmadı. Bir parola yapılandırılana kadar harici istemciler engellenecek." + } + } + } + }, + "settings.blendMode.behindWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Behind Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウの背面" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口后方" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗後方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 뒤" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinter dem Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detrás de la ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Derrière la fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dietro la finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Bag vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Za oknem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "За окном" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Iza prozora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خلف النافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bak vinduet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atrás da Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ด้านหลังหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencerenin Arkası" + } + } + } + }, + "settings.blendMode.withinWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Within Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ内" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口内部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗內" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 내" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Innerhalb des Fensters" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dentro de la ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dans la fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "All'interno della finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Inden i vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "W oknie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внутри окна" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Unutar prozora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "داخل النافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Inni vinduet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dentro da Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ภายในหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere İçinde" + } + } + } + }, + "settings.browser.externalPatterns": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "URLs to Always Open Externally" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常に外部で開くURL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "始终在外部打开的 URL" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一律在外部開啟的 URL" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "항상 외부에서 열 URL" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "URLs, die immer extern geöffnet werden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "URLs para abrir siempre externamente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "URL à toujours ouvrir dans un navigateur externe" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "URL da aprire sempre esternamente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "URL'er der altid åbnes eksternt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Adresy URL otwierane zawsze zewnętrznie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "URL для открытия во внешнем браузере" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "URL-ovi za uvijek vanjsko otvaranje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عناوين URL للفتح خارجيًا دائمًا" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "URL-er som alltid åpnes eksternt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "URLs para Sempre Abrir Externamente" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "URL ที่เปิดภายนอกเสมอ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Her Zaman Harici Olarak Açılacak URL'ler" + } + } + } + }, + "settings.browser.externalPatterns.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのリンククリックおよびインターセプトされた`open https://...`呼び出しに適用されます。1行に1ルール。プレーンテキストはURL部分文字列に一致し、`re:`プレフィックスで正規表現を使用できます(例: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "适用于终端中的链接点击和拦截的 `open https://...` 调用。每行一条规则。纯文本匹配任意 URL 子串,或使用 `re:` 前缀表示正则表达式(例如:openai.com/usage、re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "套用於終端機連結點擊和攔截的 `open https://...` 呼叫。每行一條規則。純文字會比對 URL 的任何子字串,或加上 `re:` 前綴使用正規表示式(例如:openai.com/usage、re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 링크 클릭 및 인터셉트된 `open https://...` 호출에 적용됩니다. 한 줄에 하나의 규칙. 일반 텍스트는 URL의 하위 문자열을 일치시키며, `re:` 접두사로 정규식을 사용할 수 있습니다 (예: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gilt für Klicks auf Terminal-Links und abgefangene `open https://...`-Aufrufe. Eine Regel pro Zeile. Klartext stimmt mit jedem URL-Teilstring überein, oder verwenden Sie das Präfix `re:` für Regex (zum Beispiel: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se aplica a clics en enlaces del terminal y llamadas interceptadas de `open https://...`. Una regla por línea. El texto plano coincide con cualquier subcadena de URL, o usa el prefijo `re:` para regex (por ejemplo: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "S'applique aux clics sur les liens du terminal et aux appels `open https://...` interceptés. Une règle par ligne. Le texte brut correspond à toute sous-chaîne d'URL, ou préfixez par `re:` pour une expression régulière (par exemple : openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Si applica ai clic sui link nel terminale e alle chiamate intercettate `open https://...`. Una regola per riga. Il testo semplice corrisponde a qualsiasi sottostringa dell'URL, oppure usa il prefisso `re:` per le regex (ad esempio: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gælder for terminallinksklik og opfangede `open https://...`-kald. Én regel pr. linje. Almindelig tekst matcher enhver URL-delstreng, eller brug `re:` som præfiks for regex (f.eks.: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage))." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dotyczy kliknięć linków w terminalu i przechwyconych wywołań `open https://...`. Jedna reguła na wiersz. Zwykły tekst dopasowuje dowolny fragment URL, lub użyj prefiksu `re:` dla wyrażeń regularnych (na przykład: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Применяется к нажатиям на ссылки в терминале и перехваченным вызовам `open https://...`. Одно правило на строку. Обычный текст совпадает с любой подстрокой URL, или используйте префикс `re:` для регулярных выражений (например: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Primjenjuje se na klikove linkova u terminalu i presretnute `open https://...` pozive. Jedno pravilo po redu. Običan tekst odgovara bilo kojem dijelu URL-a, ili dodajte prefiks `re:` za regex (na primjer: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ينطبق على نقرات الروابط في الطرفية واستدعاءات `open https://...` المعترضة. قاعدة واحدة لكل سطر. النص العادي يطابق أي جزء من URL، أو ابدأ بـ `re:` للتعبيرات النمطية (مثال: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjelder for terminallenkeklikk og avlyttede `open https://...`-kall. Én regel per linje. Ren tekst matcher enhver del av URL-en, eller prefiks med `re:` for regulære uttrykk (for eksempel: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aplica-se a cliques em links do terminal e chamadas interceptadas de `open https://...`. Uma regra por linha. Texto simples corresponde a qualquer substring de URL, ou prefixe com `re:` para regex (por exemplo: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้กับการคลิกลิงก์ในเทอร์มินัลและการดักจับคำสั่ง `open https://...` กฎหนึ่งข้อต่อบรรทัด ข้อความธรรมดาจะจับคู่กับส่วนใดก็ได้ของ URL หรือนำหน้าด้วย `re:` สำหรับ regex (ตัวอย่าง: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal bağlantı tıklamalarına ve yakalanan `open https://...` çağrılarına uygulanır. Satır başına bir kural. Düz metin herhangi bir URL alt dizesiyle eşleşir veya regex için `re:` ön ekini kullanın (örneğin: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + } + } + } + }, + "settings.browser.emptyImport.choose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose What to Import…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "取り込む項目を選ぶ…" + } + } + } + }, + "settings.browser.emptyImport.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import browser data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザデータを取り込む" + } + } + } + }, + "settings.browser.history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browsing History" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ履歴" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览历史" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽記錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탐색 기록" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browserverlauf" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Historial de navegación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Historique de navigation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cronologia di navigazione" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browserhistorik" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Historia przeglądania" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "История просмотра" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Historija pregledanja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سجل التصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleserhistorikk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Histórico de Navegação" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ประวัติการท่องเว็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarama Geçmişi" + } + } + } + }, + "settings.browser.import": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import From Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザから取り込む" + } + } + } + }, + "settings.browser.import.choose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択…" + } + } + } + }, + "settings.browser.import.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + } + } + }, + "settings.browser.history.clearButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear History…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴をクリア…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除历史记录..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除記錄..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 지우기…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf löschen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar historial…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer l'historique..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella cronologia…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd historik…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść historię…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить историю..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši historiju…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح السجل…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tøm historikk …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Histórico…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างประวัติ..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçmişi Temizle…" + } + } + } + }, + "settings.browser.history.clearDialog.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Annulla" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Annuller" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Anuluj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отменить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkaži" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avbryt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Vazgeç" + } + } + } + }, + "settings.browser.history.clearDialog.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear History" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴をクリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除历史记录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除記錄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar historial" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer l'historique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella cronologia" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd historik" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść historię" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить историю" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši historiju" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح السجل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tøm historikk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Histórico" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างประวัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçmişi Temizle" + } + } + } + }, + "settings.browser.history.clearDialog.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This removes visited-page suggestions from the browser omnibar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザのオムニバーから訪問済みページの候補が削除されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将从浏览器地址栏中移除已访问页面的建议。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這會移除瀏覽器網址列中已造訪頁面的建議。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 옴니바에서 방문 페이지 제안을 제거합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dies entfernt Vorschläge für besuchte Seiten aus der Browser-Adressleiste." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto elimina las sugerencias de páginas visitadas de la barra de direcciones del navegador." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela supprime les suggestions de pages visitées de la barre d'adresse du navigateur." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa operazione rimuove i suggerimenti delle pagine visitate dalla barra degli indirizzi del browser." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dette fjerner besøgte sideforslag fra browserens omnibar." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spowoduje to usunięcie podpowiedzi odwiedzonych stron z paska adresu przeglądarki." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это удалит подсказки посещенных страниц из адресной строки браузера." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo uklanja prijedloge posjećenih stranica iz omnibar preglednika." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يؤدي هذا إلى إزالة اقتراحات الصفحات المزارة من شريط عنوان المتصفح." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette fjerner forslag basert på besøkte sider fra nettleserens adressefelt." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isto remove as sugestões de páginas visitadas da barra de endereço do navegador." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การดำเนินการนี้จะลบคำแนะนำหน้าที่เยี่ยมชมจากแถบที่อยู่เบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu, tarayıcı çok amaçlı çubuğundan ziyaret edilen sayfa önerilerini kaldırır." + } + } + } + }, + "settings.browser.history.clearDialog.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear browser history?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ履歴をクリアしますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除浏览器历史记录?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要清除瀏覽記錄嗎?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 기록을 지우시겠습니까?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browserverlauf löschen?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Borrar historial del navegador?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer l'historique du navigateur ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancellare la cronologia del browser?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd browserhistorik?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyścić historię przeglądarki?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить историю браузера?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obrisati historiju preglednika?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح سجل المتصفح؟" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tømme nettleserhistorikk?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar histórico do navegador?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างประวัติเบราว์เซอร์หรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı geçmişi temizlensin mi?" + } + } + } + }, + "settings.browser.history.subtitleEmpty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No saved pages yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みのページはまだありません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚无已保存的页面。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚無已儲存的頁面。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 저장된 페이지가 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine gespeicherten Seiten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay páginas guardadas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune page enregistrée pour le moment." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna pagina salvata." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen gemte sider endnu." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak zapisanych stron." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохраненных страниц пока нет." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Još nema sačuvanih stranica." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد صفحات محفوظة بعد." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen lagrede sider ennå." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma página salva ainda." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่มีหน้าที่บันทึก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Henüz kayıtlı sayfa yok." + } + } + } + }, + "settings.browser.history.subtitleMany": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld saved pages appear in omnibar suggestions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld件の保存済みページがオムニバーの候補に表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 个已保存页面显示在地址栏建议中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 個已儲存頁面會出現在網址列建議中。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld개의 저장된 페이지가 옴니바 제안에 표시됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld gespeicherte Seiten erscheinen in den Adressleisten-Vorschlägen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas guardadas aparecen en las sugerencias de la barra de direcciones." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld pages enregistrées apparaissent dans les suggestions de la barre d'adresse." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%lld pagine salvate appaiono nei suggerimenti della barra degli indirizzi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%lld gemte sider vises i omnibar-forslag." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%lld zapisanych stron pojawia się w podpowiedziach paska adresu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохраненных страниц: %lld. Отображаются в подсказках адресной строки." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%lld sačuvanih stranica se pojavljuje u prijedlozima omnibara." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%lld صفحة محفوظة تظهر في اقتراحات شريط العنوان." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%lld lagrede sider vises i adressefeltforslag." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas salvas aparecem nas sugestões da barra de endereço." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%lld หน้าที่บันทึกจะปรากฏในคำแนะนำแถบที่อยู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%lld kayıtlı sayfa çok amaçlı çubuk önerilerinde görünür." + } + } + } + }, + "settings.browser.history.subtitleOne": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 saved page appears in omnibar suggestions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1件の保存済みページがオムニバーの候補に表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 个已保存页面显示在地址栏建议中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1 個已儲存頁面會出現在網址列建議中。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "1개의 저장된 페이지가 옴니바 제안에 표시됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "1 gespeicherte Seite erscheint in den Adressleisten-Vorschlägen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "1 página guardada aparece en las sugerencias de la barra de direcciones." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "1 page enregistrée apparaît dans les suggestions de la barre d'adresse." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "1 pagina salvata appare nei suggerimenti della barra degli indirizzi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "1 gemt side vises i omnibar-forslag." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "1 zapisana strona pojawia się w podpowiedziach paska adresu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 сохраненная страница отображается в подсказках адресной строки." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "1 sačuvana stranica se pojavljuje u prijedlozima omnibara." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "صفحة محفوظة واحدة تظهر في اقتراحات شريط العنوان." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "1 lagret side vises i adressefeltforslag." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "1 página salva aparece nas sugestões da barra de endereço." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "1 หน้าที่บันทึกจะปรากฏในคำแนะนำแถบที่อยู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "1 kayıtlı sayfa çok amaçlı çubuk önerilerinde görünür." + } + } + } + }, + "settings.browser.hostWhitelist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hosts to Open in Embedded Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ブラウザで開くホスト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在内嵌浏览器中打开的主机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在內建瀏覽器中開啟的主機" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 브라우저에서 열 호스트" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hosts, die im integrierten Browser geöffnet werden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hosts para abrir en el navegador integrado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hôtes à ouvrir dans le navigateur intégré" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Host da aprire nel browser integrato" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Værter der åbnes i den indlejrede browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Hosty do otwierania we wbudowanej przeglądarce" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хосты для открытия во встроенном браузере" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Hostovi za otvaranje u ugrađenom pregledniku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المضيفون للفتح في المتصفح المضمّن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Verter som åpnes i innebygd nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Hosts para Abrir no Navegador Integrado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฮสต์ที่เปิดในเบราว์เซอร์ในตัว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gömülü Tarayıcıda Açılacak Ana Bilgisayarlar" + } + } + } + }, + "settings.browser.hostWhitelist.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのリンククリックおよびインターセプトされた`open https://...`呼び出しに適用されます。これらのホストのみcmuxで開きます。その他はデフォルトブラウザで開きます。1行に1つのホストまたはワイルドカード(例: example.com, *.internal.example)。空欄にするとすべてのホストをcmuxで開きます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "适用于终端中的链接点击和拦截的 `open https://...` 调用。仅这些主机在 cmux 中打开,其他主机在默认浏览器中打开。每行一个主机或通配符(例如:example.com、*.internal.example)。留空则在 cmux 中打开所有主机。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "套用於終端機連結點擊和攔截的 `open https://...` 呼叫。僅這些主機會在 cmux 中開啟,其他主機會在您的預設瀏覽器中開啟。每行一個主機或萬用字元(例如:example.com、*.internal.example)。留空則在 cmux 中開啟所有主機。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 링크 클릭 및 인터셉트된 `open https://...` 호출에 적용됩니다. 이 호스트만 cmux에서 열립니다. 나머지는 기본 브라우저에서 열립니다. 한 줄에 하나의 호스트 또는 와일드카드 (예: example.com, *.internal.example). 비워두면 모든 호스트가 cmux에서 열립니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gilt für Klicks auf Terminal-Links und abgefangene `open https://...`-Aufrufe. Nur diese Hosts werden in cmux geöffnet. Andere werden in Ihrem Standardbrowser geöffnet. Ein Host oder Platzhalter pro Zeile (zum Beispiel: example.com, *.internal.example). Leer lassen, um alle Hosts in cmux zu öffnen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se aplica a clics en enlaces del terminal y llamadas interceptadas de `open https://...`. Solo estos hosts se abren en cmux. Los demás se abren en tu navegador predeterminado. Un host o comodín por línea (por ejemplo: example.com, *.internal.example). Déjalo vacío para abrir todos los hosts en cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "S'applique aux clics sur les liens du terminal et aux appels `open https://...` interceptés. Seuls ces hôtes s'ouvrent dans cmux. Les autres s'ouvrent dans votre navigateur par défaut. Un hôte ou caractère générique par ligne (par exemple : example.com, *.internal.example). Laissez vide pour ouvrir tous les hôtes dans cmux." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Si applica ai clic sui link nel terminale e alle chiamate intercettate `open https://...`. Solo questi host si aprono in cmux. Gli altri si aprono nel browser predefinito. Un host o wildcard per riga (ad esempio: example.com, *.internal.example). Lascia vuoto per aprire tutti gli host in cmux." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gælder for terminallinksklik og opfangede `open https://...`-kald. Kun disse værter åbnes i cmux. Andre åbnes i din standardbrowser. Én vært eller wildcard pr. linje (f.eks.: example.com, *.internal.example). Lad stå tomt for at åbne alle værter i cmux." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dotyczy kliknięć linków w terminalu i przechwyconych wywołań `open https://...`. Tylko te hosty otwierają się w cmux. Pozostałe otwierają się w domyślnej przeglądarce. Jeden host lub wzorzec na wiersz (na przykład: example.com, *.internal.example). Pozostaw puste, aby otwierać wszystkie hosty w cmux." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Применяется к нажатиям на ссылки в терминале и перехваченным вызовам `open https://...`. Только эти хосты открываются в cmux. Остальные открываются в браузере по умолчанию. Один хост или шаблон на строку (например: example.com, *.internal.example). Оставьте пустым, чтобы открывать все хосты в cmux." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Primjenjuje se na klikove linkova u terminalu i presretnute `open https://...` pozive. Samo se ovi hostovi otvaraju u cmux. Ostali se otvaraju u podrazumijevanom pregledniku. Jedan host ili zamjenski znak po redu (na primjer: example.com, *.internal.example). Ostavite prazno da otvorite sve hostove u cmux." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ينطبق على نقرات الروابط في الطرفية واستدعاءات `open https://...` المعترضة. فقط هؤلاء المضيفون يفتحون في cmux. البقية تفتح في متصفحك الافتراضي. مضيف واحد أو حرف بدل لكل سطر (مثال: example.com, *.internal.example). اتركه فارغًا لفتح جميع المضيفين في cmux." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjelder for terminallenkeklikk og avlyttede `open https://...`-kall. Bare disse vertene åpnes i cmux. Andre åpnes i standard nettleser. Én vert eller jokertegn per linje (for eksempel: example.com, *.internal.example). La stå tom for å åpne alle verter i cmux." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aplica-se a cliques em links do terminal e chamadas interceptadas de `open https://...`. Apenas estes hosts abrem no cmux. Outros abrem no seu navegador padrão. Um host ou curinga por linha (por exemplo: example.com, *.internal.example). Deixe vazio para abrir todos os hosts no cmux." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้กับการคลิกลิงก์ในเทอร์มินัลและการดักจับคำสั่ง `open https://...` เฉพาะโฮสต์เหล่านี้เท่านั้นที่จะเปิดใน cmux โฮสต์อื่นจะเปิดในเบราว์เซอร์เริ่มต้นของคุณ โฮสต์หรือ wildcard หนึ่งรายการต่อบรรทัด (ตัวอย่าง: example.com, *.internal.example) เว้นว่างเพื่อเปิดโฮสต์ทั้งหมดใน cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal bağlantı tıklamalarına ve yakalanan `open https://...` çağrılarına uygulanır. Yalnızca bu ana bilgisayarlar cmux'ta açılır. Diğerleri varsayılan tarayıcınızda açılır. Satır başına bir ana bilgisayar veya joker karakter (örneğin: example.com, *.internal.example). Tüm ana bilgisayarları cmux'ta açmak için boş bırakın." + } + } + } + }, + "settings.browser.httpAllowlist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "HTTP Hosts Allowed in Embedded Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ブラウザで許可するHTTPホスト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内嵌浏览器中允许的 HTTP 主机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許在內建瀏覽器中使用的 HTTP 主機" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 브라우저에서 허용할 HTTP 호스트" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "HTTP-Hosts, die im integrierten Browser zugelassen sind" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hosts HTTP permitidos en el navegador integrado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hôtes HTTP autorisés dans le navigateur intégré" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Host HTTP consentiti nel browser integrato" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "HTTP-værter tilladt i den indlejrede browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Hosty HTTP dozwolone we wbudowanej przeglądarce" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "HTTP-хосты, разрешенные во встроенном браузере" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "HTTP hostovi dozvoljeni u ugrađenom pregledniku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مضيفو HTTP المسموح بهم في المتصفح المضمّن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "HTTP-verter tillatt i innebygd nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Hosts HTTP Permitidos no Navegador Integrado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฮสต์ HTTP ที่อนุญาตในเบราว์เซอร์ในตัว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gömülü Tarayıcıda İzin Verilen HTTP Ana Bilgisayarları" + } + } + } + }, + "settings.browser.httpAllowlist.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Controls which HTTP (non-HTTPS) hosts can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "警告プロンプトなしでcmuxで開けるHTTP(非HTTPS)ホストを制御します。デフォルトにはlocalhost、127.0.0.1、::1、0.0.0.0、*.localtest.meが含まれます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制哪些 HTTP(非 HTTPS)主机可以在 cmux 中打开而不显示警告提示。默认包括 localhost、127.0.0.1、::1、0.0.0.0 和 *.localtest.me。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "控制哪些 HTTP(非 HTTPS)主機可以在 cmux 中開啟而不顯示警告提示。預設包含 localhost、127.0.0.1、::1、0.0.0.0 和 *.localtest.me。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux에서 경고 없이 열 수 있는 HTTP(비HTTPS) 호스트를 제어합니다. 기본값에는 localhost, 127.0.0.1, ::1, 0.0.0.0 및 *.localtest.me가 포함됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steuert, welche HTTP-Hosts (nicht HTTPS) ohne Warnhinweis in cmux geöffnet werden können. Standardmäßig enthalten: localhost, 127.0.0.1, ::1, 0.0.0.0 und *.localtest.me." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Controla qué hosts HTTP (no HTTPS) pueden abrirse en cmux sin un mensaje de advertencia. Los valores predeterminados incluyen localhost, 127.0.0.1, ::1, 0.0.0.0 y *.localtest.me." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôle quels hôtes HTTP (non HTTPS) peuvent s'ouvrir dans cmux sans avertissement. Les valeurs par défaut incluent localhost, 127.0.0.1, ::1, 0.0.0.0 et *.localtest.me." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Controlla quali host HTTP (non HTTPS) possono aprirsi in cmux senza un avviso. I valori predefiniti includono localhost, 127.0.0.1, ::1, 0.0.0.0 e *.localtest.me." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Styrer hvilke HTTP-værter (ikke-HTTPS) der kan åbnes i cmux uden advarselsmeddelelse. Standardindstillinger inkluderer localhost, 127.0.0.1, ::1, 0.0.0.0 og *.localtest.me." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Określa, które hosty HTTP (inne niż HTTPS) mogą być otwierane w cmux bez ostrzeżenia. Domyślnie uwzględniono localhost, 127.0.0.1, ::1, 0.0.0.0 i *.localtest.me." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Определяет, какие HTTP (не HTTPS) хосты могут открываться в cmux без предупреждения. По умолчанию включены localhost, 127.0.0.1, ::1, 0.0.0.0 и *.localtest.me." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kontroliše koji HTTP (ne-HTTPS) hostovi mogu biti otvoreni u cmux bez upozorenja. Podrazumijevani uključuju localhost, 127.0.0.1, ::1, 0.0.0.0 i *.localtest.me." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتحكم في مضيفي HTTP (غير HTTPS) الذين يمكنهم الفتح في cmux بدون رسالة تحذير. الافتراضيات تشمل localhost و 127.0.0.1 و ::1 و 0.0.0.0 و *.localtest.me." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Styrer hvilke HTTP-verter (ikke HTTPS) som kan åpnes i cmux uten advarselsmelding. Standardverdier inkluderer localhost, 127.0.0.1, ::1, 0.0.0.0 og *.localtest.me." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Controla quais hosts HTTP (não HTTPS) podem abrir no cmux sem um aviso. Os padrões incluem localhost, 127.0.0.1, ::1, 0.0.0.0 e *.localtest.me." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ควบคุมว่าโฮสต์ HTTP (ไม่ใช่ HTTPS) ใดที่สามารถเปิดใน cmux โดยไม่ต้องแสดงคำเตือน ค่าเริ่มต้นรวมถึง localhost, 127.0.0.1, ::1, 0.0.0.0 และ *.localtest.me" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hangi HTTP (HTTPS olmayan) ana bilgisayarlarının cmux'ta uyarı istemi olmadan açılabileceğini kontrol eder. Varsayılanlar localhost, 127.0.0.1, ::1, 0.0.0.0 ve *.localtest.me içerir." + } + } + } + }, + "settings.browser.httpAllowlist.hint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1行に1つのホストまたはワイルドカード(例: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每行一个主机或通配符(例如:localhost、127.0.0.1、::1、0.0.0.0、*.localtest.me)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每行一個主機或萬用字元(例如:localhost、127.0.0.1、::1、0.0.0.0、*.localtest.me)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한 줄에 하나의 호스트 또는 와일드카드 (예: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Host oder Platzhalter pro Zeile (zum Beispiel: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un host o comodín por línea (por ejemplo: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un hôte ou caractère générique par ligne (par exemple : localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un host o wildcard per riga (ad esempio: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Én vært eller wildcard pr. linje (f.eks.: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jeden host lub wzorzec na wiersz (na przykład: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Один хост или шаблон на строку (например: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Jedan host ili zamjenski znak po redu (na primjer: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مضيف واحد أو حرف بدل لكل سطر (مثال: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Én vert eller jokertegn per linje (for eksempel: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Um host ou curinga por linha (por exemplo: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฮสต์หรือ wildcard หนึ่งรายการต่อบรรทัด (ตัวอย่าง: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Satır başına bir ana bilgisayar veya joker karakter (örneğin: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + } + } + } + }, + "settings.browser.httpAllowlist.save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Speichern" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Guardar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Salva" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gem" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapisz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Spremi" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حفظ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lagre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Salvar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บันทึก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaydet" + } + } + } + }, + "settings.browser.interceptOpen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Intercept open http(s) in Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルでopen http(s)をインターセプト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在终端中拦截 open http(s)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在終端機中攔截 open http(s)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널에서 open http(s) 인터셉트" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "open http(s) im Terminal abfangen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Interceptar open http(s) en Terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intercepter open http(s) dans le terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Intercetta open http(s) nel terminale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opfang open http(s) i terminal" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przechwytuj open http(s) w terminalu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перехватывать open http(s) в терминале" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Presretni open http(s) u Terminalu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اعتراض open http(s) في الطرفية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avlytt open http(s) i terminal" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Interceptar open http(s) no Terminal" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ดักจับ open http(s) ในเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminalde open http(s) Komutlarını Yakala" + } + } + } + }, + "settings.browser.interceptOpen.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When off, `open https://...` and `open http://...` always use your default browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフの場合、`open https://...`および`open http://...`は常にデフォルトブラウザを使用します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭后,`open https://...` 和 `open http://...` 始终使用默认浏览器。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉時,`open https://...` 和 `open http://...` 一律使用您的預設瀏覽器。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비활성화하면 `open https://...` 및 `open http://...`가 항상 기본 브라우저를 사용합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn deaktiviert, verwenden `open https://...` und `open http://...` immer Ihren Standardbrowser." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuando está desactivado, `open https://...` y `open http://...` siempre usan tu navegador predeterminado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lorsque désactivé, `open https://...` et `open http://...` utilisent toujours votre navigateur par défaut." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quando disattivato, `open https://...` e `open http://...` usano sempre il browser predefinito." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Når deaktiveret, bruger `open https://...` og `open http://...` altid din standardbrowser." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po wyłączeniu `open https://...` i `open http://...` zawsze używają domyślnej przeglądarki." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При отключении `open https://...` и `open http://...` всегда используют браузер по умолчанию." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kada je isključeno, `open https://...` i `open http://...` uvijek koriste podrazumijevani preglednik." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عند التعطيل، يستخدم `open https://...` و `open http://...` دائمًا متصفحك الافتراضي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når av, bruker `open https://...` og `open http://...` alltid standard nettleser." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Quando desativado, `open https://...` e `open http://...` sempre usam seu navegador padrão." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เมื่อปิด `open https://...` และ `open http://...` จะใช้เบราว์เซอร์เริ่มต้นของคุณเสมอ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapalıyken, `open https://...` ve `open http://...` her zaman varsayılan tarayıcınızı kullanır." + } + } + } + }, + "settings.browser.openTerminalLinks": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Terminal Links in cmux Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのリンクをcmuxブラウザで開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 浏览器中打开终端链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 瀏覽器中開啟終端機連結" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 링크를 cmux 브라우저에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Links im cmux-Browser öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir enlaces del terminal en el navegador de cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les liens du terminal dans le navigateur cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri link del terminale nel browser cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn terminallinks i cmux-browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwieraj linki terminala w przeglądarce cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открывать ссылки из терминала в браузере cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori linkove terminala u cmux pregledniku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح روابط الطرفية في متصفح cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne terminallenker i cmux-nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Links do Terminal no Navegador do cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดลิงก์เทอร์มินัลในเบราว์เซอร์ cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Bağlantılarını cmux Tarayıcısında Aç" + } + } + } + }, + "settings.browser.openTerminalLinks.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When off, links clicked in terminal output open in your default browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフの場合、ターミナル出力のリンクはデフォルトブラウザで開きます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭后,终端输出中点击的链接在默认浏览器中打开。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉時,終端機輸出中點擊的連結會在您的預設瀏覽器中開啟。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비활성화하면 터미널 출력에서 클릭한 링크가 기본 브라우저에서 열립니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn deaktiviert, werden im Terminal angeklickte Links in Ihrem Standardbrowser geöffnet." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuando está desactivado, los enlaces en la salida del terminal se abren en tu navegador predeterminado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lorsque désactivé, les liens cliqués dans la sortie du terminal s'ouvrent dans votre navigateur par défaut." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quando disattivato, i link cliccati nell'output del terminale si aprono nel browser predefinito." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Når deaktiveret, åbnes links, der klikkes i terminaloutput, i din standardbrowser." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po wyłączeniu linki kliknięte w terminalu otwierają się w domyślnej przeglądarce." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При отключении ссылки из терминала открываются в браузере по умолчанию." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kada je isključeno, linkovi kliknuti u izlazu terminala se otvaraju u podrazumijevanom pregledniku." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عند التعطيل، تفتح الروابط المنقورة في مخرجات الطرفية في متصفحك الافتراضي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når av, åpnes lenker som klikkes i terminalutdata i standard nettleser." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Quando desativado, links clicados na saída do terminal abrem no seu navegador padrão." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เมื่อปิด ลิงก์ที่คลิกในเอาต์พุตเทอร์มินัลจะเปิดในเบราว์เซอร์เริ่มต้นของคุณ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapalıyken, terminal çıktısında tıklanan bağlantılar varsayılan tarayıcınızda açılır." + } + } + } + }, + "settings.browser.searchEngine": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default Search Engine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト検索エンジン" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认搜索引擎" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設搜尋引擎" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 검색 엔진" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardsuchmaschine" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Motor de búsqueda predeterminado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moteur de recherche par défaut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Motore di ricerca predefinito" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Standardsøgemaskine" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Domyślna wyszukiwarka" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поисковая система по умолчанию" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podrazumijevani pretraživač" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "محرك البحث الافتراضي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Standard søkemotor" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Motor de Busca Padrão" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เครื่องมือค้นหาเริ่มต้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Varsayılan Arama Motoru" + } + } + } + }, + "settings.browser.searchEngine.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used by the browser address bar when input is not a URL." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザのアドレスバーで入力がURLでない場合に使用されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器地址栏中输入非 URL 内容时使用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當瀏覽器網址列的輸入不是 URL 時使用。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력이 URL이 아닌 경우 브라우저 주소 표시줄에서 사용됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird von der Browser-Adressleiste verwendet, wenn die Eingabe keine URL ist." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usado por la barra de direcciones del navegador cuando la entrada no es una URL." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisé par la barre d'adresse du navigateur lorsque la saisie n'est pas une URL." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Utilizzato dalla barra degli indirizzi del browser quando l'input non è un URL." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Bruges af browserens adresselinje, når input ikke er en URL." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Używana przez pasek adresu przeglądarki, gdy dane wejściowe nie są adresem URL." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Используется адресной строкой браузера, когда ввод не является URL." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Koristi se u adresnoj traci preglednika kada unos nije URL." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يُستخدم بواسطة شريط عنوان المتصفح عندما لا يكون الإدخال URL." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Brukes av adressefeltet i nettleseren når inndataen ikke er en URL." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Usado pela barra de endereço do navegador quando a entrada não é uma URL." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้โดยแถบที่อยู่เบราว์เซอร์เมื่ออินพุตไม่ใช่ URL" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Giriş bir URL olmadığında tarayıcı adres çubuğu tarafından kullanılır." + } + } + } + }, + "settings.browser.searchSuggestions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Search Suggestions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索候補を表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示搜索建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示搜尋建議" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 제안 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchvorschläge anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar sugerencias de búsqueda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les suggestions de recherche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra suggerimenti di ricerca" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis søgeforslag" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż podpowiedzi wyszukiwania" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать поисковые подсказки" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži prijedloge pretrage" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض اقتراحات البحث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis søkeforslag" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Sugestões de Pesquisa" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงคำแนะนำการค้นหา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Arama Önerilerini Göster" + } + } + } + }, + "settings.browser.theme": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Theme" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザテーマ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器主题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器主題" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 테마" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser-Design" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tema del navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Thème du navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tema del browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browsertema" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Motyw przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тема браузера" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tema preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مظهر المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettlesertema" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tema do Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ธีมเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı Teması" + } + } + } + }, + "settings.browser.theme.subtitleForced": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ forces that color scheme for compatible pages." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@は対応ページにそのカラースキームを強制します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 为兼容页面强制使用该配色方案。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 會為相容的頁面強制套用該色彩配置。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@은(는) 호환 페이지에 해당 색상 구성표를 강제 적용합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ erzwingt dieses Farbschema für kompatible Seiten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ fuerza ese esquema de colores para las páginas compatibles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ impose ce jeu de couleurs aux pages compatibles." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ impone quello schema di colori per le pagine compatibili." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@ tvinger det farveskema for kompatible sider." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ wymusza ten schemat kolorów dla zgodnych stron." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ принудительно применяет эту цветовую схему для совместимых страниц." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@ nameće tu shemu boja za kompatibilne stranice." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ يفرض نظام الألوان هذا للصفحات المتوافقة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@ tvinger det fargeskjemaet for kompatible sider." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ força esse esquema de cores para páginas compatíveis." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@ บังคับใช้โทนสีนั้นสำหรับหน้าที่รองรับ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ uyumlu sayfalar için bu renk şemasını zorlar." + } + } + } + }, + "settings.browser.theme.subtitleSystem": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System follows app and macOS appearance." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムはアプリとmacOSの外観に従います。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统跟随应用和 macOS 外观。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "「系統」會跟隨 App 和 macOS 外觀。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템은 앱 및 macOS 외관을 따릅니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System folgt der App- und macOS-Darstellung." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sistema sigue la apariencia de la app y macOS." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système suit l'apparence de l'app et de macOS." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sistema segue l'aspetto dell'app e di macOS." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "System følger app- og macOS-udseende." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Systemowy podąża za wyglądem aplikacji i macOS." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системная тема следует за оформлением приложения и macOS." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sistemski prati izgled aplikacije i macOS." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النظام يتبع مظهر التطبيق و macOS." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "System følger app- og macOS-utseende." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sistema segue a aparência do app e do macOS." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระบบจะตามธีมแอปและรูปลักษณ์ macOS" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem, uygulama ve macOS görünümünü takip eder." + } + } + } + }, + "settings.material.contentBackground": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Content Background" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンテンツ背景" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内容背景" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "內容背景" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "콘텐츠 배경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inhaltshintergrund" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fondo de contenido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrière-plan du contenu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sfondo contenuto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indholdsbaggrund" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tło zawartości" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фон содержимого" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pozadina sadržaja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خلفية المحتوى" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Innholdsbakgrunn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fundo de Conteúdo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นหลังเนื้อหา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İçerik Arka Planı" + } + } + } + }, + "settings.material.fullScreenUI": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Full Screen UI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルスクリーンUI" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全屏 UI" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全螢幕 UI" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 화면 UI" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollbild-UI" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Interfaz a pantalla completa" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Interface plein écran" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "UI a schermo intero" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fuldskærms-UI" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pełnoekranowy interfejs" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полноэкранный интерфейс" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "UI punog ekrana" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "واجهة ملء الشاشة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fullskjerm-UI" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Interface em Tela Cheia" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "UI เต็มหน้าจอ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tam Ekran Kullanıcı Arayüzü" + } + } + } + }, + "settings.material.headerView": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Header View" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘッダビュー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "头部视图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標題列" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "헤더 뷰" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopfansicht" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vista de encabezado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vue d'en-tête" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vista intestazione" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Headervisning" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Widok nagłówka" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заголовок" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zaglavlje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الرأس" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Topptekstvisning" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cabeçalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "มุมมองส่วนหัว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Başlık Görünümü" + } + } + } + }, + "settings.material.hudWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "HUD Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "HUDウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "HUD 窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "HUD 視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "HUD 윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "HUD-Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana HUD" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre HUD" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra HUD" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "HUD-vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Okno HUD" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "HUD-окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "HUD prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة HUD" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "HUD-vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela HUD" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่าง HUD" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "HUD Penceresi" + } + } + } + }, + "settings.material.liquidGlass": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass(macOS 26以降)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass (macOS 26+)" + } + } + } + }, + "settings.material.menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニュー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "菜单" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選單" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메뉴" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Menü" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Menú" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Menu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Menu" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Menu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Menu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Меню" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Meni" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "القائمة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Meny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Menu" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เมนู" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Menü" + } + } + } + }, + "settings.material.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ninguno" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ništa" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بدون" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhum" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มี" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yok" + } + } + } + }, + "settings.material.popover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Popover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポップオーバー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "弹出框" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "彈出框" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팝오버" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Popover" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Popover" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Popover" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Popover" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Popover" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyskakujące okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всплывающее окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Iskočni prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة منبثقة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Popover" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Popover" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ป็อปโอเวอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açılır Pencere" + } + } + } + }, + "settings.material.sheet": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sheet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シート" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作表" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作表" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시트" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dialogblatt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hoja" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Feuille" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Foglio" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ark" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Arkusz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Лист" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "List" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ورقة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ark" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Folha" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ชีท" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sayfa" + } + } + } + }, + "settings.material.sidebar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "侧边栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "側邊欄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sidebjælke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pasek boczny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Боковая панель" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Bočna traka" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sidepanel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğu" + } + } + } + }, + "settings.material.toolTip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tool Tip" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツールチップ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工具提示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工具提示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도구 설명" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tooltip" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descripción emergente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Info-bulle" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Suggerimento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Værktøjstip" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podpowiedź" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсказка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Opis alata" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تلميح أداة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Verktøytips" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dica de Ferramenta" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำแนะนำเครื่องมือ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Araç İpucu" + } + } + } + }, + "settings.material.underWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Under Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ下" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口底部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗下方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 아래" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unter dem Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Debajo de la ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sous la fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sotto la finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Under vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pod oknem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Под окном" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ispod prozora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحت النافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Under vinduet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sob a Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใต้หน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere Altı" + } + } + } + }, + "settings.material.windowBackground": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window Background" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ背景" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口背景" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗背景" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 배경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fensterhintergrund" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fondo de ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrière-plan de la fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sfondo finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vinduesbaggrund" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tło okna" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фон окна" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pozadina prozora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خلفية النافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindubakgrunn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fundo da Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นหลังหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere Arka Planı" + } + } + } + }, + "settings.preset.hudGlass": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "HUD Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "HUD Glass" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "HUD 玻璃" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "HUD 玻璃" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "HUD 글래스" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "HUD Glass" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "HUD Glass" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "HUD Glass" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "HUD Glass" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "HUD-glas" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szkło HUD" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "HUD Glass" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "HUD staklo" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "زجاج HUD" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "HUD-glass" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Vidro HUD" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "HUD Glass" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "HUD Glass" + } + } + } + }, + "settings.preset.nativeSidebar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Native Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネイティブサイドバー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原生侧边栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原生側邊欄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 사이드바" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Native Seitenleiste" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Barra lateral nativa" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Barre latérale native" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Barra laterale nativa" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indbygget sidebjælke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Natywny pasek boczny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системная боковая панель" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nativna bočna traka" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "شريط جانبي أصلي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Innebygd sidepanel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Barra Lateral Nativa" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แถบด้านข้างแบบดั้งเดิม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yerel Kenar Çubuğu" + } + } + } + }, + "settings.preset.popoverGlass": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Popover Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポップオーバーGlass" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "弹出框玻璃" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "彈出框玻璃" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팝오버 글래스" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Popover Glass" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Popover Glass" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Popover Glass" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Popover Glass" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Popover-glas" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szkło wyskakujące" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Popover Glass" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Iskočno staklo" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "زجاج منبثق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Popover-glass" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Vidro Popover" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Popover Glass" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açılır Pencere Glass" + } + } + } + }, + "settings.preset.raycastGray": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Raycast Gray" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Raycast Gray" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Raycast 灰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Raycast 灰" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Raycast 그레이" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Raycast Gray" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Raycast Gray" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Raycast Gray" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Raycast Gray" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Raycast-grå" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szary Raycast" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Raycast Gray" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Raycast siva" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رمادي Raycast" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Raycast-grå" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cinza Raycast" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Raycast Gray" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Raycast Gri" + } + } + } + }, + "settings.preset.softBlur": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Soft Blur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソフトブラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "柔和模糊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "柔和模糊" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소프트 블러" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Soft Blur" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Soft Blur" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Soft Blur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Soft Blur" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Blød sløring" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Delikatne rozmycie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Soft Blur" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Meko zamućenje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ضبابية ناعمة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Myk uskarphet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desfoque Suave" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Soft Blur" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yumuşak Bulanıklık" + } + } + } + }, + "settings.preset.underWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Under Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ下" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口底部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗下方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 아래" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Under Window" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Under Window" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Under Window" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Under Window" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Under vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pod oknem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Under Window" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ispod prozora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحت النافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Under vinduet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sob a Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Under Window" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere Altı" + } + } + } + }, + "settings.reset.resetAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset All Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべての設定をリセット" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置所有设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置所有設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 설정 초기화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Einstellungen zurücksetzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer todos los ajustes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser tous les réglages" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ripristina tutte le impostazioni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nulstil alle indstillinger" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Resetuj wszystkie ustawienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбросить все настройки" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Resetuj sve postavke" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تعيين جميع الإعدادات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilbakestill alle innstillinger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redefinir Todos os Ajustes" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเซ็ตการตั้งค่าทั้งหมด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tüm Ayarları Sıfırla" + } + } + } + }, + "settings.section.app": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "App" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "App" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "App" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "App" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "App" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Aplikacja" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приложение" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Aplikacija" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التطبيق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "App" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "App" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แอป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uygulama" + } + } + } + }, + "settings.section.automation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オートメーション" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisierung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Automatización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Automatisation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Automazione" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Automatisering" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Automatyzacja" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматизация" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Automatizacija" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأتمتة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Automatisering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Automação" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระบบอัตโนมัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomasyon" + } + } + } + }, + "settings.section.browser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przeglądarka" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браузер" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preglednik" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı" + } + } + } + }, + "settings.section.keyboardShortcuts": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keyboard Shortcuts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーボードショートカット" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "键盘快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鍵盤快速鍵" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키보드 단축키" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastaturkurzbefehle" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Atajos de teclado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Raccourcis clavier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Abbreviazioni da tastiera" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tastaturgenveje" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Skróty klawiaturowe" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сочетания клавиш" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prečice na tastaturi" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختصارات لوحة المفاتيح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tastatursnarveier" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atalhos de Teclado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แป้นพิมพ์ลัด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klavye Kısayolları" + } + } + } + }, + "settings.section.reset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "초기화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ripristina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nulstil" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Resetuj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сброс" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Resetovanje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التعيين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilbakestill" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redefinir" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเซ็ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sıfırla" + } + } + } + }, + "settings.section.workspaceColors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace Colors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースカラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區顏色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 색상" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereichsfarben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Colores de espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleurs des espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colori area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområdefarver" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolory przestrzeni roboczych" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвета рабочих пространств" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boje radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ألوان مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområdefarger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cores da Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı Renkleri" + } + } + } + }, + "settings.shortcuts.recordHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Click a shortcut value to record a new shortcut." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカット値をクリックして新しいショートカットを記録します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击快捷键值以录制新快捷键。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按一下快速鍵值以錄製新的快速鍵。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축키 값을 클릭하여 새 단축키를 기록하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klicken Sie auf einen Kurzbefehlswert, um einen neuen Kurzbefehl aufzunehmen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Haz clic en un valor de atajo para grabar un nuevo atajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cliquez sur une valeur de raccourci pour en enregistrer un nouveau." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fai clic su un valore di scorciatoia per registrarne una nuova." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Klik på en genvejsværdi for at optage en ny genvej." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kliknij wartość skrótu, aby nagrać nowy skrót." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите на значение сочетания, чтобы записать новое." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kliknite na vrijednost prečice da snimite novu prečicu." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقر على قيمة اختصار لتسجيل اختصار جديد." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Klikk på en snarveiverdi for å ta opp en ny snarvei." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Clique em um valor de atalho para gravar um novo atalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คลิกค่าทางลัดเพื่อบันทึกทางลัดใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni bir kısayol kaydetmek için bir kısayol değerine tıklayın." + } + } + } + }, + "settings.shortcuts.showHints": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Cmd/Ctrl-Hold Shortcut Hints" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cmd/Ctrl長押しのショートカットヒントを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示 Cmd/Ctrl 长按快捷键提示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示 Cmd/Ctrl 按住時的快速鍵提示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cmd/Ctrl 누르고 있을 때 단축키 힌트 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cmd/Ctrl-Gedrückthalten-Kurzbefehlhinweise anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar indicaciones de atajo al mantener Cmd/Ctrl" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les indications de raccourcis avec Cmd/Ctrl enfoncé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra suggerimenti scorciatoie Cmd/Ctrl" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis Cmd/Ctrl-hold genvejstip" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż podpowiedzi skrótów przy przytrzymaniu Cmd/Ctrl" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать подсказки сочетаний при удержании Cmd/Ctrl" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži savjete za prečice pri držanju Cmd/Ctrl" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض تلميحات اختصارات Cmd/Ctrl عند الضغط المطول" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis Cmd/Ctrl-hold snarveihint" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Dicas de Atalho ao Segurar Cmd/Ctrl" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงคำแนะนำทางลัด Cmd/Ctrl ค้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cmd/Ctrl Basılı Tutma Kısayol İpuçlarını Göster" + } + } + } + }, + "settings.shortcuts.showHints.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Holding Cmd or Ctrl keeps shortcut hint pills hidden." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CmdまたはCtrlを長押ししてもショートカットヒントピルは非表示のままです。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "长按 Cmd 或 Ctrl 时隐藏快捷键提示标签。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按住 Cmd 或 Ctrl 時不顯示快速鍵提示標籤。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cmd 또는 Ctrl을 누르고 있어도 단축키 힌트 표시가 숨겨집니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Gedrückthalten von Cmd oder Ctrl hält die Kurzbefehlhinweise ausgeblendet." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantener Cmd o Ctrl mantiene ocultas las indicaciones de atajos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Maintenir Cmd ou Ctrl garde les pastilles d'indication de raccourcis masquées." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tenere premuto Cmd o Ctrl mantiene nascosti i suggerimenti delle scorciatoie." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "At holde Cmd eller Ctrl holder genvejstip skjulte." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przytrzymanie Cmd lub Ctrl nie pokazuje podpowiedzi skrótów." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удержание Cmd или Ctrl не показывает подсказки сочетаний." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Držanje Cmd ili Ctrl drži savjete za prečice skrivenim." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الضغط المطول على Cmd أو Ctrl يبقي كبسولات التلميح مخفية." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Å holde Cmd eller Ctrl holder snarveihintpillene skjult." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Segurar Cmd ou Ctrl mantém as pílulas de dica de atalho ocultas." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การกด Cmd หรือ Ctrl ค้างจะซ่อนป้ายคำแนะนำทางลัด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cmd veya Ctrl basılı tutulduğunda kısayol ipucu rozetleri gizli kalır." + } + } + } + }, + "settings.shortcuts.showHints.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Holding Cmd (sidebar/titlebar) or Ctrl/Cmd (pane tabs) shows shortcut hint pills." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cmd(サイドバー/タイトルバー)またはCtrl/Cmd(ペインタブ)を長押しするとショートカットヒントピルが表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "长按 Cmd(侧边栏/标题栏)或 Ctrl/Cmd(面板标签页)时显示快捷键提示标签。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按住 Cmd(側邊欄/標題列)或 Ctrl/Cmd(面板標籤頁)時顯示快速鍵提示標籤。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cmd(사이드바/제목 표시줄) 또는 Ctrl/Cmd(패널 탭)를 누르고 있으면 단축키 힌트가 표시됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Gedrückthalten von Cmd (Seitenleiste/Titelleiste) oder Ctrl/Cmd (Bereichs-Tabs) zeigt Kurzbefehlhinweise an." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantener Cmd (barra lateral/barra de título) o Ctrl/Cmd (pestañas del panel) muestra las indicaciones de atajos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Maintenir Cmd (barre latérale/barre de titre) ou Ctrl/Cmd (onglets de panneau) affiche les pastilles d'indication de raccourcis." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tenere premuto Cmd (barra laterale/barra del titolo) o Ctrl/Cmd (schede pannello) mostra i suggerimenti delle scorciatoie." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "At holde Cmd (sidebjælke/titellinje) eller Ctrl/Cmd (panelfaner) viser genvejstip." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przytrzymanie Cmd (pasek boczny/pasek tytułu) lub Ctrl/Cmd (karty panelu) pokazuje podpowiedzi skrótów." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удержание Cmd (боковая панель/заголовок) или Ctrl/Cmd (вкладки панели) показывает подсказки сочетаний." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Držanje Cmd (bočna traka/naslovna traka) ili Ctrl/Cmd (tabovi panela) prikazuje savjete za prečice." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الضغط المطول على Cmd (الشريط الجانبي/شريط العنوان) أو Ctrl/Cmd (ألسنة اللوحات) يعرض كبسولات التلميح." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Å holde Cmd (sidepanel/tittellinje) eller Ctrl/Cmd (panelfaner) viser snarveihintpiller." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Segurar Cmd (barra lateral/barra de título) ou Ctrl/Cmd (abas do painel) mostra pílulas de dica de atalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การกด Cmd ค้าง (แถบด้านข้าง/แถบชื่อ) หรือ Ctrl/Cmd (แท็บบานหน้าต่าง) จะแสดงป้ายคำแนะนำทางลัด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cmd (kenar çubuğu/başlık çubuğu) veya Ctrl/Cmd (bölme sekmeleri) basılı tutulduğunda kısayol ipucu rozetleri gösterilir." + } + } + } + }, + "settings.state.active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "活跃" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활성" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktiv" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actif" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attivo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Aktiv" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Aktywny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активное" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Aktivno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نشط" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktiv" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ativo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้งานอยู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Etkin" + } + } + } + }, + "settings.state.followWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Follow Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウに追従" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跟随窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跟隨視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 따르기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster folgen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seguir ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivre la fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segui finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Følg vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podążaj za oknem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следовать за окном" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prati prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "متابعة النافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Følg vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Seguir Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตามหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencereyi Takip Et" + } + } + } + }, + "settings.state.inactive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inactive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "非アクティブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不活跃" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "非使用中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비활성" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inaktiv" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inactivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inactif" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inattivo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Inaktiv" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieaktywny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неактивное" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Neaktivno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير نشط" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Inaktiv" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Inativo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ได้ใช้งาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Etkin Değil" + } + } + } + }, + "settings.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réglages" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impostazioni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indstillinger" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ustawienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Postavke" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإعدادات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Innstillinger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ajustes" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การตั้งค่า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarlar" + } + } + } + }, + "settings.workspaceColors.base": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Base: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベース: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "基础色:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "基底:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Basis: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Base: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Base : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Base: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Base: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Bazowy: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Базовый: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Osnovna: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأساس: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Basis: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Base: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ฐาน: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Temel: %@" + } + } + } + }, + "settings.workspaceColors.customColors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Colors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムカラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂顏色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 색상" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Farben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Colores personalizados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleurs personnalisées" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colori personalizzati" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brugerdefinerede farver" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Własne kolory" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользовательские цвета" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prilagođene boje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ألوان مخصصة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Egendefinerte farger" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cores Personalizadas" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีที่กำหนดเอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel Renkler" + } + } + } + }, + "settings.workspaceColors.indicator": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace Color Indicator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースカラーインジケーター" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区颜色指示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區顏色指示器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 색상 표시기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereichsfarb-Indikator" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Indicador de color del espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Indicateur de couleur d'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Indicatore colore area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområdefarveindikator" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wskaźnik koloru przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Индикатор цвета рабочего пространства" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Indikator boje radnog prostora" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مؤشر لون مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområdefarge-indikator" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Indicador de Cor da Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตัวบ่งชี้สีเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı Renk Göstergesi" + } + } + } + }, + "settings.workspaceColors.noCustomColors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムカラー: まだありません。ワークスペースのコンテキストメニューから「カスタムカラーを選択…」を使用してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义颜色:暂无。请从工作区右键菜单中使用「选取自定义颜色...」。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂顏色:尚無。請從工作區右鍵選單中使用「選擇自訂顏色...」。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 색상: 아직 없습니다. 작업 공간 컨텍스트 메뉴에서 \"사용자 지정 색상 선택...\"을 사용하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Farben: noch keine. Verwenden Sie 'Eigene Farbe wählen ...' im Kontextmenü eines Arbeitsbereichs." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Colores personalizados: ninguno aún. Usa \"Elegir color personalizado…\" desde el menú contextual de un espacio de trabajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleurs personnalisées : aucune pour le moment. Utilisez « Choisir une couleur personnalisée... » depuis le menu contextuel d'un espace de travail." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colori personalizzati: nessuno. Usa \"Scegli colore personalizzato...\" dal menu contestuale di un'area di lavoro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brugerdefinerede farver: ingen endnu. Brug \"Vælg brugerdefineret farve...\" fra en arbejdsområdekontekstmenu." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Własne kolory: jeszcze żadnych. Użyj „Wybierz własny kolor…” z menu kontekstowego przestrzeni roboczej." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользовательские цвета: пока нет. Используйте «Выбрать пользовательский цвет...» из контекстного меню рабочего пространства." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prilagođene boje: još nema. Koristite \"Odaberi prilagođenu boju...\" iz kontekstnog menija radnog prostora." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ألوان مخصصة: لا يوجد بعد. استخدم \"اختيار لون مخصص...\" من قائمة سياق مساحة العمل." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Egendefinerte farger: ingen ennå. Bruk «Velg egendefinert farge ...» fra en arbeidsområde-kontekstmeny." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cores personalizadas: nenhuma ainda. Use \"Escolher Cor Personalizada...\" no menu de contexto de uma área de trabalho." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีที่กำหนดเอง: ยังไม่มี ใช้ \"เลือกสีที่กำหนดเอง...\" จากเมนูบริบทเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel renkler: henüz yok. Bir çalışma alanı bağlam menüsünden \"Özel Renk Seç...\" seçeneğini kullanın." + } + } + } + }, + "settings.workspaceColors.paletteNote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Customize the workspace color palette used by Sidebar > Workspace Color. \"Choose Custom Color...\" entries are persisted below." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバー > ワークスペースカラーで使用するカラーパレットをカスタマイズできます。「カスタムカラーを選択…」のエントリは以下に保存されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义「侧边栏 > 工作区颜色」中使用的工作区调色板。「选取自定义颜色...」的条目将保存在下方。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂側邊欄 > 工作區顏色使用的色板。「選擇自訂顏色...」的項目會保存在下方。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 > 작업 공간 색상에서 사용하는 작업 공간 색상 팔레트를 사용자 지정합니다. \"사용자 지정 색상 선택...\" 항목은 아래에 저장됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passen Sie die Arbeitsbereichs-Farbpalette an, die unter Seitenleiste > Arbeitsbereichsfarbe verwendet wird. Einträge unter 'Eigene Farbe wählen ...' werden unten gespeichert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Personaliza la paleta de colores de espacios de trabajo usada en Barra lateral > Color del espacio de trabajo. Las entradas de \"Elegir color personalizado…\" se guardan a continuación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Personnalisez la palette de couleurs utilisée par Barre latérale > Couleur de l'espace de travail. Les entrées « Choisir une couleur personnalisée... » sont enregistrées ci-dessous." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Personalizza la palette di colori utilizzata da Barra laterale > Colore area di lavoro. Le voci \"Scegli colore personalizzato...\" vengono salvate qui sotto." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilpas arbejdsområdets farvepalette, der bruges af Sidebjælke > Arbejdsområdefarve. \"Vælg brugerdefineret farve...\"-poster gemmes nedenfor." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dostosuj paletę kolorów przestrzeni roboczej używaną przez Pasek boczny > Kolor przestrzeni roboczej. Wpisy „Wybierz własny kolor…” są zapisywane poniżej." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройте палитру цветов рабочих пространств, используемую в Боковая панель > Цвет рабочего пространства. Записи «Выбрать пользовательский цвет...» сохраняются ниже." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prilagodite paletu boja radnog prostora koju koristi Bočna traka > Boja radnog prostora. Unosi \"Odaberi prilagođenu boju...\" su trajno sačuvani ispod." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخصيص لوحة ألوان مساحة العمل المستخدمة بواسطة الشريط الجانبي > لون مساحة العمل. إدخالات \"اختيار لون مخصص...\" تُحفظ أدناه." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilpass arbeidsområdefargepaletten som brukes av Sidepanel > Arbeidsområdefarge. «Velg egendefinert farge ...»-oppføringer lagres nedenfor." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Personalize a paleta de cores usada em Barra Lateral > Cor da Área de Trabalho. Entradas de \"Escolher Cor Personalizada...\" são salvas abaixo." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปรับแต่งจานสีเวิร์กสเปซที่ใช้ใน แถบด้านข้าง > สีเวิร์กสเปซ รายการ \"เลือกสีที่กำหนดเอง...\" จะถูกบันทึกด้านล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğu > Çalışma Alanı Rengi tarafından kullanılan çalışma alanı renk paletini özelleştirin. \"Özel Renk Seç...\" girişleri aşağıda saklanır." + } + } + } + }, + "settings.workspaceColors.remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제거" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloni" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaldır" + } + } + } + }, + "settings.workspaceColors.resetPalette": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset Palette" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パレットをリセット" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置调色板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置色板" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팔레트 초기화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Palette zurücksetzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer paleta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser la palette" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ripristina palette" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nulstil palette" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Resetuj paletę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбросить палитру" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Resetuj paletu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تعيين اللوحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilbakestill palett" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redefinir Paleta" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเซ็ตจานสี" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Paleti Sıfırla" + } + } + } + }, + "settings.workspaceColors.resetPalette.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "초기화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ripristina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nulstil" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Resetuj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбросить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Resetuj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تعيين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilbakestill" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redefinir" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเซ็ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sıfırla" + } + } + } + }, + "settings.workspaceColors.resetPalette.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore built-in defaults and clear all custom colors." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "組み込みのデフォルトに戻し、すべてのカスタムカラーをクリアします。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复内置默认值并清除所有自定义颜色。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "恢復內建預設值並清除所有自訂顏色。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 제공 값을 복원하고 모든 사용자 지정 색상을 지웁니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Integrierte Standardeinstellungen wiederherstellen und alle benutzerdefinierten Farben löschen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restaurar los valores predeterminados y borrar todos los colores personalizados." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Restaurer les valeurs par défaut et effacer toutes les couleurs personnalisées." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ripristina i valori predefiniti e cancella tutti i colori personalizzati." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gendan indbyggede standardindstillinger og ryd alle brugerdefinerede farver." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przywróć wbudowane wartości domyślne i wyczyść wszystkie własne kolory." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановить встроенные значения по умолчанию и удалить все пользовательские цвета." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vrati ugrađene podrazumijevane vrijednosti i obriši sve prilagođene boje." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "استعادة الإعدادات الافتراضية المدمجة ومسح جميع الألوان المخصصة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjenopprett innebygde standardverdier og fjern alle egendefinerte farger." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Restaurar padrões integrados e limpar todas as cores personalizadas." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คืนค่าเริ่มต้นในตัวและล้างสีที่กำหนดเองทั้งหมด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yerleşik varsayılanları geri yükle ve tüm özel renkleri temizle." + } + } + } + }, + "shortcut.closeWindow.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer la fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق النافذة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencereyi Kapat" + } + } + } + }, + "shortcut.closeWorkspace.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij przestrzeń roboczą" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Kapat" + } + } + } + }, + "shortcut.flashFocusedPanel.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Flash Focused Panel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカスペインを強調" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "闪烁聚焦面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閃爍聚焦面板" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포커스된 패널 깜빡이기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokussierten Bereich hervorheben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resaltar panel enfocado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Flasher le panneau actif" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Evidenzia pannello attivo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fremhæv fokuseret panel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podświetl aktywny panel" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсветить активную панель" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi fokusirani panel" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وميض اللوحة المركّزة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Blink fokusert panel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Piscar Painel em Foco" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กะพริบแผงที่โฟกัส" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Odaklanılan Paneli Yanıp Söndür" + } + } + } + }, + "shortcut.focusPaneDown.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Pane Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下のペインにフォーカス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聚焦下方面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聚焦下方面板" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래쪽 패널로 포커스" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereich unten fokussieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enfocar panel inferior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le panneau du bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta focus pannello in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fokuser panel nedad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Fokus na panel poniżej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фокус на панель снизу" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Fokusiraj panel dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التركيز على اللوحة أسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fokuser panel ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Focar Painel Abaixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฟกัสบานหน้าต่างด้านล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aşağıdaki Bölmeye Odaklan" + } + } + } + }, + "shortcut.focusPaneLeft.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Pane Left" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左のペインにフォーカス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聚焦左侧面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聚焦左側面板" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽 패널로 포커스" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereich links fokussieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enfocar panel izquierdo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le panneau de gauche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta focus pannello a sinistra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fokuser panel til venstre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Fokus na panel po lewej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фокус на панель слева" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Fokusiraj panel lijevo" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التركيز على اللوحة يسار" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fokuser panel venstre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Focar Painel à Esquerda" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฟกัสบานหน้าต่างด้านซ้าย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Soldaki Bölmeye Odaklan" + } + } + } + }, + "shortcut.focusPaneRight.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Pane Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右のペインにフォーカス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聚焦右侧面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聚焦右側面板" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽 패널로 포커스" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereich rechts fokussieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enfocar panel derecho" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le panneau de droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta focus pannello a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fokuser panel til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Fokus na panel po prawej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фокус на панель справа" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Fokusiraj panel desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التركيز على اللوحة يمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fokuser panel høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Focar Painel à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฟกัสบานหน้าต่างด้านขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sağdaki Bölmeye Odaklan" + } + } + } + }, + "shortcut.focusPaneUp.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Pane Up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上のペインにフォーカス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聚焦上方面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聚焦上方面板" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위쪽 패널로 포커스" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereich oben fokussieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enfocar panel superior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le panneau du haut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta focus pannello in alto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fokuser panel opad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Fokus na panel powyżej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фокус на панель сверху" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Fokusiraj panel gore" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التركيز على اللوحة أعلى" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fokuser panel opp" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Focar Painel Acima" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฟกัสบานหน้าต่างด้านบน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yukarıdaki Bölmeye Odaklan" + } + } + } + }, + "shortcut.jumpToUnread.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to Latest Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の未読にジャンプ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳转到最新未读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跳至最新未讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신 읽지 않은 항목으로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zur letzten ungelesenen springen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ir a la última no leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aller au dernier message non lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vai all'ultimo non letto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gå til seneste ulæste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przejdź do najnowszego nieprzeczytanego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к последнему непрочитанному" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Skoči na najnovije nepročitano" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتقال إلى أحدث غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gå til siste uleste" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ir para Última Não Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้ามไปยังรายการยังไม่อ่านล่าสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Son Okunmamışa Atla" + } + } + } + }, + "shortcut.newSurface.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Surface" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規サーフェス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建 Surface" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增 Surface" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 화면" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue Oberfläche" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva superficie" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle surface" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova superficie" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ny overflade" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa powierzchnia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новая поверхность" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nova površina" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سطح جديد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ny flate" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Superfície" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นผิวใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Yüzey" + } + } + } + }, + "shortcut.newWindow.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 윈도우" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neues Fenster" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova finestra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt vindue" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowe okno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое окно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi prozor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt vindu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Janela" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่างใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Pencere" + } + } + } + }, + "shortcut.newWorkspace.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Çalışma Alanı" + } + } + } + }, + "shortcut.nextSurface.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next Surface" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のサーフェス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一个 Surface" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一個 Surface" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 화면" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächste Oberfläche" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Siguiente superficie" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surface suivante" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Superficie successiva" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Næste overflade" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Następna powierzchnia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следующая поверхность" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sljedeća površina" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "السطح التالي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Neste flate" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Próxima Superfície" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นผิวถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonraki Yüzey" + } + } + } + }, + "shortcut.nextWorkspace.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一个工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一個工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächster Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Siguiente espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail suivant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro successiva" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Næste arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Następna przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следующее рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sljedeći radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل التالية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Neste arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Próxima Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonraki Çalışma Alanı" + } + } + } + }, + "shortcut.openBrowser.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz przeglądarkę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть браузер" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori preglednik" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Aç" + } + } + } + }, + "shortcut.openFolder.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟資料夾" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordner öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir carpeta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir un dossier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri cartella" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn mappe" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz folder" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть папку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori folder" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح مجلد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne mappe" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir Pasta" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดโฟลเดอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasör Aç" + } + } + } + }, + "shortcut.pressShortcut.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Press shortcut…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカットを入力…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请按快捷键..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請按快速鍵..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축키를 누르세요…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastenkürzel drücken …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pulsa un atajo…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appuyez sur un raccourci..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Premi la scorciatoia…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tryk genvej…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Naciśnij skrót…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите сочетание клавиш..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pritisnite prečicu…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اضغط الاختصار…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Trykk snarvei …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pressione o atalho…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กดแป้นพิมพ์ลัด..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kısayola basın…" + } + } + } + }, + "shortcut.previousSurface.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous Surface" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前のサーフェス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一个 Surface" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一個 Surface" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 화면" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorherige Oberfläche" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Superficie anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surface précédente" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Superficie precedente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forrige overflade" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Poprzednia powierzchnia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущая поверхность" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prethodna površina" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "السطح السابق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forrige flate" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Superfície Anterior" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นผิวก่อนหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önceki Yüzey" + } + } + } + }, + "shortcut.previousWorkspace.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前のワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一个工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一個工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorheriger Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail précédent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro precedente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forrige arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Poprzednia przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущее рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prethodni radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل السابقة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forrige arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho Anterior" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซก่อนหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önceki Çalışma Alanı" + } + } + } + }, + "shortcut.renameTab.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ名を変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 이름 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab umbenennen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar pestaña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'onglet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina scheda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę karty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать вкладку" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj tab" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية اللسان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi fanen nytt navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Aba" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อแท็บ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeyi Yeniden Adlandır" + } + } + } + }, + "shortcut.renameWorkspace.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名を変更" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich umbenennen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rinomina area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omdøb arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień nazwę przestrzeni roboczej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preimenuj radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تسمية مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi arbeidsområdet nytt navn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Renomear Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Yeniden Adlandır" + } + } + } + }, + "shortcut.showBrowserJSConsole.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Browser JavaScript Console" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ JavaScript コンソールを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示浏览器 JavaScript 控制台" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示瀏覽器 JavaScript 主控台" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 JavaScript 콘솔 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser-JavaScript-Konsole anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar consola de JavaScript del navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la console JavaScript du navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra console JavaScript del browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis browser JavaScript-konsol" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż konsolę JavaScript przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать консоль JavaScript в браузере" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži JavaScript konzolu preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض وحدة تحكم JavaScript للمتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis JavaScript-konsoll i nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Console JavaScript do Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงคอนโซล JavaScript ของเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı JavaScript Konsolunu Göster" + } + } + } + }, + "shortcut.showNotifications.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimleri Göster" + } + } + } + }, + "shortcut.splitBrowserDown.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Browser Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを下に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向下拆分浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向下分割瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저를 아래로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser nach unten teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir navegador hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser le navigateur vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi browser in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel browser nedad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel przeglądarkę w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить браузер вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli preglednik dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم المتصفح للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del nettleser ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir Navegador para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกเบราว์เซอร์ลงล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Aşağı Böl" + } + } + } + }, + "shortcut.splitBrowserRight.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Browser Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを右に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右拆分浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右分割瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저를 오른쪽으로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser nach rechts teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir navegador a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser le navigateur à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi browser a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel browser til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel przeglądarkę w prawo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить браузер вправо" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli preglednik desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم المتصفح لليمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del nettleser til høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir Navegador à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกเบราว์เซอร์ไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıyı Sağa Böl" + } + } + } + }, + "shortcut.splitDown.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向下拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向下分割" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach unten teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel nedad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกลงล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aşağı Böl" + } + } + } + }, + "shortcut.splitRight.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右分割" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽으로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach rechts teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel w prawo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить вправо" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم لليمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del til høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sağa Böl" + } + } + } + }, + "shortcut.toggleBrowserDevTools.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Browser Developer Tools" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザデベロッパツールを切替" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换浏览器开发者工具" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換瀏覽器開發者工具" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 개발자 도구 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser-Entwicklerwerkzeuge ein-/ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar herramientas de desarrollo del navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher/masquer les outils de développement du navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva Strumenti sviluppatore browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå browserudviklerværktøjer til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz narzędzia deweloperskie przeglądarki" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Инструменты разработчика браузера" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci razvojne alate preglednika" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل أدوات المطور للمتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå utviklerverktøy i nettleser av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Ferramentas do Desenvolvedor do Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับเครื่องมือนักพัฒนาเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı Geliştirici Araçlarını Aç/Kapat" + } + } + } + }, + "shortcut.togglePaneZoom.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Pane Zoom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインズームを切替" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换面板缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換面板縮放" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "패널 확대/축소 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereichszoom umschalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar zoom del panel" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer/désactiver le zoom du panneau" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva zoom pannello" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå panelzoom til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz powiększenie panelu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Масштаб панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci uvećanje panela" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل تكبير اللوحة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå panelzoom av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Zoom do Painel" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับการซูมบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölme Yakınlaştırmasını Aç/Kapat" + } + } + } + }, + "shortcut.toggleSidebar.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーを切替" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换侧边栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換側邊欄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste ein-/ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher/masquer la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå sidebjælke til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz pasek boczny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Боковая панель" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci bočnu traku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå sidepanelet av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunu Aç/Kapat" + } + } + } + }, + "shortcut.toggleTerminalCopyMode.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Terminal Copy Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルコピーモードを切替" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换终端复制模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換終端機複製模式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 복사 모드 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Kopiermodus umschalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar modo de copia del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer/désactiver le mode copie du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva modalità copia terminale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå terminalkopiertilstand til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz tryb kopiowania terminala" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим копирования терминала" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci režim kopiranja terminala" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل وضع النسخ في الطرفية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå terminalkopieringsmodus av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Modo de Cópia do Terminal" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับโหมดคัดลอกเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Kopyalama Modunu Aç/Kapat" + } + } + } + }, + "sidebar.closeWorkspace.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 닫기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l'espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiudi area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Luk arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknij przestrzeń roboczą" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatvori radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lukk arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fechar Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanını Kapat" + } + } + } + }, + "sidebar.folderIcon.dragHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Drag to open in Finder or another app" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドラッグしてFinderまたは他のアプリで開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拖拽以在访达或其他应用中打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拖曳以在 Finder 或其他 App 中開啟" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Finder 또는 다른 앱으로 드래그하여 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ziehen, um im Finder oder einer anderen App zu öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Arrastra para abrir en Finder u otra app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Glissez pour ouvrir dans le Finder ou une autre app" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trascina per aprire nel Finder o in un'altra app" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Træk for at åbne i Finder eller et andet program" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przeciągnij, aby otworzyć w Finderze lub innej aplikacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перетащите, чтобы открыть в Finder или другом приложении" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prevucite za otvaranje u Finderu ili drugoj aplikaciji" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسحب للفتح في Finder أو تطبيق آخر" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dra for å åpne i Finder eller en annen app" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Arraste para abrir no Finder ou em outro app" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลากเพื่อเปิดใน Finder หรือแอปอื่น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Finder'da veya başka bir uygulamada açmak için sürükleyin" + } + } + } + }, + "sidebar.indicator.leftRail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left Rail" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左レール" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左侧导轨" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "左側軌道" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽 레일" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Linke Leiste" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Barra izquierda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rail gauche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Barra sinistra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Venstre skinne" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Lewa listwa" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Левая полоса" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Lijeva traka" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "شريط أيسر" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Venstre skinne" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Trilho Esquerdo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แถบด้านซ้าย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sol Şerit" + } + } + } + }, + "sidebar.indicator.solidFill": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Solid Fill" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソリッドフィル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "纯色填充" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "實心填充" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단색 채우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbfüllung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Relleno sólido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remplissage uni" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riempimento solido" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Solid udfyldning" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jednolite wypełnienie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сплошная заливка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Puna ispuna" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعبئة صلبة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Heldekkende fyll" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Preenchimento Sólido" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นทึบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düz Dolgu" + } + } + } + }, + "sidebar.metadata.showLess": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show less" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示を減らす" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收起" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示較少" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "간략히 보기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weniger anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar menos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher moins" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra meno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis mindre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż mniej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать меньше" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži manje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض أقل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis mindre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar menos" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงน้อยลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Daha az göster" + } + } + } + }, + "sidebar.metadata.showLessDetails": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show less details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細を折りたたむ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收起详细信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示較少詳細資訊" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부 정보 접기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weniger Details anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar menos detalles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher moins de détails" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra meno dettagli" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis færre detaljer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż mniej szczegółów" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать меньше подробностей" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži manje detalja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض تفاصيل أقل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis færre detaljer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar menos detalhes" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงรายละเอียดน้อยลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Daha az ayrıntı göster" + } + } + } + }, + "sidebar.metadata.showMore": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show more" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "さらに表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "展开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示更多" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 보기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mehr anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar más" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher plus" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra di più" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis mere" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż więcej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать больше" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži više" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض المزيد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis mer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar mais" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงเพิ่มเติม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Daha fazla göster" + } + } + } + }, + "sidebar.metadata.showMoreDetails": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show more details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細を展開" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "展开详细信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示更多詳細資訊" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부 정보 펼치기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mehr Details anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar más detalles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher plus de détails" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra più dettagli" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis flere detaljer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż więcej szczegółów" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать больше подробностей" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži više detalja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض تفاصيل أكثر" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis flere detaljer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar mais detalhes" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงรายละเอียดเพิ่มเติม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Daha fazla ayrıntı göster" + } + } + } + }, + "sidebar.pathMenu.macintoshHD": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Macintosh HD" + } + } + } + }, + "sidebar.pullRequest.openTooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open %1$@ #%2$lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ #%2$lldを開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 %1$@ #%2$lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟 %1$@ #%2$lld" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ #%2$lld 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ #%2$lld öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir %1$@ #%2$lld" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir %1$@ #%2$lld" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri %1$@ #%2$lld" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn %1$@ #%2$lld" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz %1$@ #%2$lld" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть %1$@ #%2$lld" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori %1$@ #%2$lld" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح %1$@ #%2$lld" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne %1$@ #%2$lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir %1$@ #%2$lld" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิด %1$@ #%2$lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ #%2$lld Aç" + } + } + } + }, + "sidebar.pullRequest.statusClosed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "closed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クローズ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已關閉" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫힘" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "geschlossen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cerrado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "fermée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "chiusa" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "lukket" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "zamknięty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "закрыт" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "zatvoren" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مغلق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "lukket" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "fechado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "kapalı" + } + } + } + }, + "sidebar.pullRequest.statusMerged": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "merged" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マージ済み" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已合并" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已合併" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "병합됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "zusammengeführt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "fusionado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "fusionnée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "unita" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "sammenlagt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "scalony" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "объединен" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "spojen" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مدمج" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "sammenslått" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "mesclado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ผสานแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "birleştirildi" + } + } + } + }, + "sidebar.pullRequest.statusOpen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オープン" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "열림" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "offen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "abierto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "ouverte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "aperta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "åben" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "otwarty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "открыт" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "otvoren" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مفتوح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "åpen" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "aberto" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "açık" + } + } + } + }, + "sidebar.workspace.accessibilityHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティブにしてこのワークスペースにフォーカスします。ドラッグで並べ替え、または「上に移動」「下に移動」アクションを使用します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "激活以聚焦此工作区。拖拽以重新排序,或使用上移和下移操作。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用以聚焦此工作區。拖曳以重新排序,或使用上移和下移動作。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 작업 공간에 포커스하려면 활성화하세요. 드래그하여 순서를 변경하거나 위로 이동 및 아래로 이동 동작을 사용하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren, um diesen Arbeitsbereich zu fokussieren. Ziehen zum Umordnen oder verwenden Sie die Aktionen 'Nach oben' und 'Nach unten'." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activa para enfocar este espacio de trabajo. Arrastra para reordenar, o usa las acciones Mover hacia arriba y Mover hacia abajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez pour accéder à cet espace de travail. Glissez pour réordonner, ou utilisez les actions Déplacer vers le haut et Déplacer vers le bas." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva per portare il focus su questa area di lavoro. Trascina per riordinare oppure usa le azioni Sposta in alto e Sposta in basso." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Aktiver for at fokusere dette arbejdsområde. Træk for at omarrangere, eller brug handlingerne Flyt op og Flyt ned." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Aktywuj, aby ustawić fokus na tej przestrzeni roboczej. Przeciągnij, aby zmienić kolejność, lub użyj akcji Przenieś w górę i Przenieś w dół." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активируйте для перехода к этому рабочему пространству. Перетащите для изменения порядка или используйте действия «Переместить вверх» и «Переместить вниз»." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Aktivirajte za fokusiranje ovog radnog prostora. Prevucite za preraspoređivanje ili koristite akcije Pomjeri gore i Pomjeri dolje." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فعّل للتركيز على مساحة العمل هذه. اسحب لإعادة الترتيب، أو استخدم إجراءات نقل للأعلى ونقل للأسفل." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktiver for å fokusere dette arbeidsområdet. Dra for å omorganisere, eller bruk Flytt opp og Flytt ned." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ative para focar nesta área de trabalho. Arraste para reordenar ou use as ações Mover para Cima e Mover para Baixo." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดใช้งานเพื่อโฟกัสเวิร์กสเปซนี้ ลากเพื่อจัดเรียงใหม่ หรือใช้การกระทำเลื่อนขึ้นและเลื่อนลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu çalışma alanına odaklanmak için etkinleştirin. Yeniden sıralamak için sürükleyin veya Yukarı Taşı ve Aşağı Taşı eylemlerini kullanın." + } + } + } + }, + "sidebar.workspace.moveDownAction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下移" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下移" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach unten bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt ned" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pomjeri dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลื่อนลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aşağı Taşı" + } + } + } + }, + "sidebar.workspace.moveUpAction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上に移動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上移" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上移" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach oben bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover hacia arriba" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer vers le haut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta in alto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt op" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś w górę" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить вверх" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pomjeri gore" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل للأعلى" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt opp" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mover para Cima" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลื่อนขึ้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yukarı Taşı" + } + } + } + }, + "socketControl.allowAll.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow any local process and user to connect with no auth. Unsafe." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証なしで任意のローカルプロセスおよびユーザーの接続を許可します。安全ではありません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许任何本地进程和用户无需认证即可连接。不安全。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許任何本機程序和使用者連線,無需驗證。不安全。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인증 없이 모든 로컬 프로세스 및 사용자의 연결을 허용합니다. 안전하지 않습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jeden lokalen Prozess und Benutzer ohne Authentifizierung verbinden lassen. Unsicher." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitir que cualquier proceso y usuario local se conecte sin autenticación. Inseguro." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autoriser tout processus et utilisateur local à se connecter sans authentification. Non sécurisé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Consenti a qualsiasi processo e utente locale di connettersi senza autenticazione. Non sicuro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tillad enhver lokal proces og bruger at forbinde uden autentifikation. Usikkert." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zezwól dowolnemu lokalnemu procesowi i użytkownikowi na połączenie bez uwierzytelniania. Niebezpieczne." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешить подключение любому локальному процессу и пользователю без авторизации. Небезопасно." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dozvolite bilo kojem lokalnom procesu i korisniku da se poveže bez autentikacije. Nesigurno." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "السماح لأي عملية ومستخدم محلي بالاتصال بدون مصادقة. غير آمن." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tillat enhver lokal prosess og bruker å koble til uten autentisering. Usikkert." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Permitir que qualquer processo e usuário local se conecte sem autenticação. Inseguro." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อนุญาตให้กระบวนการในเครื่องและผู้ใช้ทุกคนเชื่อมต่อโดยไม่ต้องยืนยันตัวตน ไม่ปลอดภัย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Herhangi bir yerel işlem ve kullanıcının kimlik doğrulama olmadan bağlanmasına izin ver. Güvensiz." + } + } + } + }, + "socketControl.allowAll.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Full open access" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全オープンアクセス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完全开放访问" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "完全開放存取" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 개방 접근" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollständiger offener Zugriff" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acceso abierto completo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accès ouvert complet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Accesso aperto completo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fuld åben adgang" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pełny otwarty dostęp" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полный открытый доступ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Potpuni otvoreni pristup" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وصول مفتوح كامل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Full åpen tilgang" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Acesso aberto total" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การเข้าถึงเปิดแบบเต็ม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tam açık erişim" + } + } + } + }, + "socketControl.automation.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow external local automation clients from this macOS user (no ancestry check)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この macOS ユーザーからの外部ローカル自動化クライアントを許可します(プロセス系譜チェックなし)。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许当前 macOS 用户的外部本地自动化客户端连接(不检查进程来源)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許此 macOS 使用者的外部本機自動化用戶端(不檢查來源)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 macOS 사용자의 외부 로컬 자동화 클라이언트를 허용합니다 (출처 확인 없음)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Externe lokale Automatisierungs-Clients dieses macOS-Benutzers zulassen (keine Abstammungsprüfung)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitir clientes de automatización locales externos de este usuario de macOS (sin verificación de ascendencia)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autoriser les clients d'automatisation locaux externes de cet utilisateur macOS (sans vérification de parenté)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Consenti ai client di automazione locale esterni di questo utente macOS (senza controllo di discendenza)." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tillad eksterne lokale automatiseringsklienter fra denne macOS-bruger (ingen herkomstkontrol)." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zezwól zewnętrznym lokalnym klientom automatyzacji od tego użytkownika macOS (bez sprawdzania pochodzenia)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешить внешним клиентам автоматизации от текущего пользователя macOS (без проверки происхождения)." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dozvolite vanjskim lokalnim klijentima automatizacije od ovog macOS korisnika (bez provjere porijekla)." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "السماح لعملاء الأتمتة المحليين الخارجيين من مستخدم macOS هذا (بدون فحص النسب)." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tillat eksterne lokale automatiseringsklienter fra denne macOS-brukeren (ingen opphavskontroll)." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Permitir clientes de automação locais externos deste usuário macOS (sem verificação de ancestralidade)." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อนุญาตไคลเอ็นต์ระบบอัตโนมัติภายนอกจากผู้ใช้ macOS นี้ (ไม่ตรวจสอบสายสืบทอด)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu macOS kullanıcısından gelen harici yerel otomasyon istemcilerine izin ver (soy denetimi yok)." + } + } + } + }, + "socketControl.automation.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automation mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動化モード" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动化模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動化模式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화 모드" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisierungsmodus" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Modo de automatización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode automatisation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Modalità automazione" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Automatiseringstilstand" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tryb automatyzacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим автоматизации" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Režim automatizacije" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وضع الأتمتة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Automatiseringsmodus" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Modo de automação" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหมดระบบอัตโนมัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomasyon modu" + } + } + } + }, + "socketControl.cmuxOnly.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only processes started inside cmux terminals can send commands." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux ターミナル内で起動したプロセスのみがコマンドを送信できます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅允许在 cmux 终端内启动的进程发送命令。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅在 cmux 終端機內啟動的程序可以傳送指令。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 터미널 내에서 시작된 프로세스만 명령을 보낼 수 있습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Prozesse, die in cmux-Terminals gestartet wurden, können Befehle senden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo los procesos iniciados dentro de terminales de cmux pueden enviar comandos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Seuls les processus lancés dans les terminaux cmux peuvent envoyer des commandes." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Solo i processi avviati nei terminali cmux possono inviare comandi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kun processer startet i cmux-terminaler kan sende kommandoer." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tylko procesy uruchomione wewnątrz terminali cmux mogą wysyłać polecenia." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только процессы, запущенные в терминалах cmux, могут отправлять команды." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Samo procesi pokrenuti unutar cmux terminala mogu slati naredbe." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فقط العمليات التي بدأت داخل طرفيات cmux يمكنها إرسال الأوامر." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bare prosesser startet innenfor cmux-terminaler kan sende kommandoer." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Apenas processos iniciados dentro de terminais do cmux podem enviar comandos." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เฉพาะกระบวนการที่เริ่มต้นภายในเทอร์มินัล cmux เท่านั้นที่สามารถส่งคำสั่งได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yalnızca cmux terminalleri içinden başlatılan işlemler komut gönderebilir." + } + } + } + }, + "socketControl.cmuxOnly.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux processes only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux プロセスのみ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅限 cmux 进程" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅限 cmux 程序" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 프로세스만" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur cmux-Prozesse" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo procesos de cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Processus cmux uniquement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Solo processi cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kun cmux-processer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tylko procesy cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только процессы cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Samo cmux procesi" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عمليات cmux فقط" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kun cmux-prosesser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Apenas processos do cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กระบวนการ cmux เท่านั้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yalnızca cmux işlemleri" + } + } + } + }, + "socketControl.error.passwordFilePath": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to resolve socket password file path." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソケットパスワードファイルのパスを解決できません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法解析套接字密码文件路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法解析 Socket 密碼檔案路徑。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소켓 비밀번호 파일 경로를 확인할 수 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Pfad zur Socket-Passwortdatei konnte nicht aufgelöst werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo resolver la ruta del archivo de contraseña del socket." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de résoudre le chemin du fichier de mot de passe du socket." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile risolvere il percorso del file password del socket." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke bestemme stien til socket-adgangskodefilen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie można rozwiązać ścieżki pliku hasła gniazda." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось определить путь к файлу пароля сокета." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće razriješiti putanju datoteke lozinke utičnice." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تحديد مسار ملف كلمة مرور المقبس." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kan ikke finne filstien for socket-passord." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível resolver o caminho do arquivo de senha do socket." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถระบุเส้นทางไฟล์รหัสผ่านซ็อกเก็ตได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Soket parola dosyası yolu çözümlenemedi." + } + } + } + }, + "socketControl.off.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable the local control socket." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカルコントロールソケットを無効にします。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用本地控制套接字。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用本機控制 Socket。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 제어 소켓을 비활성화합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Den lokalen Steuerungs-Socket deaktivieren." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivar el socket de control local." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver le socket de contrôle local." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disabilita il socket di controllo locale." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Deaktiver den lokale kontrolsocket." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyłącz lokalne gniazdo sterujące." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключить локальный управляющий сокет." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Onemogući lokalnu kontrolnu utičnicu." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعطيل مقبس التحكم المحلي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Deaktiver den lokale kontrollsocketen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desativar o socket de controle local." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดใช้งานซ็อกเก็ตควบคุมในเครื่อง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yerel kontrol soketini devre dışı bırak." + } + } + } + }, + "socketControl.off.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끔" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disattivato" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyłączone" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выключено" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Isključeno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Av" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desativado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapalı" + } + } + } + }, + "socketControl.password.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Require socket authentication with a password stored in a local file." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカルファイルに保存されたパスワードでソケット認証を要求します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用存储在本地文件中的密码进行套接字认证。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用儲存在本機檔案中的密碼進行 Socket 驗證。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 파일에 저장된 비밀번호로 소켓 인증을 요구합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Socket-Authentifizierung mit einem in einer lokalen Datei gespeicherten Passwort erfordern." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Requerir autenticación del socket con una contraseña almacenada en un archivo local." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exiger l'authentification du socket avec un mot de passe stocké dans un fichier local." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Richiedi l'autenticazione del socket con una password memorizzata in un file locale." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kræv socket-autentifikation med en adgangskode gemt i en lokal fil." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wymagaj uwierzytelniania gniazda hasłem przechowywanym w pliku lokalnym." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требовать аутентификацию сокета с паролем, хранящимся в локальном файле." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zahtijevaj autentikaciju utičnice lozinkom pohranjenom u lokalnoj datoteci." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "طلب مصادقة المقبس بكلمة مرور مخزنة في ملف محلي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Krev socket-autentisering med et passord lagret i en lokal fil." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Exigir autenticação do socket com uma senha armazenada em um arquivo local." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ต้องมีการยืนยันตัวตนซ็อกเก็ตด้วยรหัสผ่านที่จัดเก็บในไฟล์ในเครื่อง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yerel dosyada saklanan bir parola ile soket kimlik doğrulaması gerektir." + } + } + } + }, + "socketControl.password.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワードモード" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密码模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "密碼模式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호 모드" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwortmodus" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Modo de contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode mot de passe" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Modalità password" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Adgangskodetilstand" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tryb hasła" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим пароля" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Režim lozinke" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وضع كلمة المرور" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Passordmodus" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Modo de senha" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหมดรหัสผ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Parola modu" + } + } + } + }, + "statusMenu.clearAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear All" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてクリア" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部清除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除全部" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두 지우기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar todo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout effacer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella tutto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ryd alle" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyczyść wszystko" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить все" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obriši sve" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح الكل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern alle" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Limpar Tudo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ล้างทั้งหมด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tümünü Temizle" + } + } + } + }, + "statusMenu.jumpToLatestUnread": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to Latest Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の未読にジャンプ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳转到最新未读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跳至最新未讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신 읽지 않은 항목으로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zur letzten ungelesenen springen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ir a la última no leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aller au dernier message non lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Vai all'ultimo non letto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gå til seneste ulæste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przejdź do najnowszego nieprzeczytanego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к последнему непрочитанному" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Skoči na najnovije nepročitano" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتقال إلى أحدث غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gå til siste uleste" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ir para Última Não Lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้ามไปยังรายการยังไม่อ่านล่าสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Son Okunmamışa Atla" + } + } + } + }, + "statusMenu.markAllRead": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark All Read" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて既読にする" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部标记为已读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部標為已讀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두 읽음으로 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle als gelesen markieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Marcar todo como leído" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout marquer comme lu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segna tutto come letto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Marker alle som læste" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oznacz wszystko jako przeczytane" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить все как прочитанные" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Označi sve kao pročitano" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليم الكل كمقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Merk alle som lest" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Marcar Tudo como Lido" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำเครื่องหมายว่าอ่านทั้งหมดแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tümünü Okundu İşaretle" + } + } + } + }, + "statusMenu.noUnread": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No unread notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未読の通知はありません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有未读通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有未讀通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽지 않은 알림 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine ungelesenen Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin notificaciones no leídas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune notification non lue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna notifica non letta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen ulæste notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak nieprzeczytanych powiadomień" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет непрочитанных уведомлений" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nema nepročitanih obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد إشعارات غير مقروءة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen uleste varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma notificação não lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีการแจ้งเตือนที่ยังไม่อ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Okunmamış bildirim yok" + } + } + } + }, + "statusMenu.showNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimleri Göster" + } + } + } + }, + "statusMenu.tooltip.unread.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 unread notification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未読通知 1件" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 条未读通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1 則未讀通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽지 않은 알림 1개" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "1 ungelesene Benachrichtigung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "1 notificación no leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "1 notification non lue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "1 notifica non letta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "1 ulæst notifikation" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "1 nieprzeczytane powiadomienie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 непрочитанное уведомление" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "1 nepročitano obavještenje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إشعار واحد غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "1 ulest varsel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "1 notificação não lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "1 การแจ้งเตือนที่ยังไม่อ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "1 okunmamış bildirim" + } + } + } + }, + "statusMenu.tooltip.unread.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld unread notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未読通知 %lld件" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 条未读通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 則未讀通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽지 않은 알림 %lld개" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld ungelesene Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%lld notificaciones no leídas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld notifications non lues" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%lld notifiche non lette" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%lld ulæste notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%lld nieprzeczytanych powiadomień" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Непрочитанных уведомлений: %lld" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%lld nepročitanih obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%lld إشعار غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%lld uleste varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%lld notificações não lidas" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%lld การแจ้งเตือนที่ยังไม่อ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%lld okunmamış bildirim" + } + } + } + }, + "statusMenu.unreadCount.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 unread notification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未読通知 1件" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 条未读通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1 則未讀通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽지 않은 알림 1개" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "1 ungelesene Benachrichtigung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "1 notificación no leída" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "1 notification non lue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "1 notifica non letta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "1 ulæst notifikation" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "1 nieprzeczytane powiadomienie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 непрочитанное уведомление" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "1 nepročitano obavještenje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إشعار واحد غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "1 ulest varsel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "1 notificação não lida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "1 การแจ้งเตือนที่ยังไม่อ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "1 okunmamış bildirim" + } + } + } + }, + "statusMenu.unreadCount.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld unread notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未読通知 %lld件" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 条未读通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 則未讀通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽지 않은 알림 %lld개" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld ungelesene Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%lld notificaciones no leídas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld notifications non lues" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%lld notifiche non lette" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%lld ulæste notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%lld nieprzeczytanych powiadomień" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Непрочитанных уведомлений: %lld" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%lld nepročitanih obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%lld إشعار غير مقروء" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%lld uleste varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%lld notificações não lidas" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%lld การแจ้งเตือนที่ยังไม่อ่าน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%lld okunmamış bildirim" + } + } + } + }, + "tab.untitled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名称未設定タブ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未命名标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未命名標籤頁" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제목 없는 탭" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbenannter Tab" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pestaña sin título" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet sans titre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda senza titolo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Unavngivet fane" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karta bez nazwy" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Безымянная вкладка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tab bez naziva" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لسان بلا عنوان" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Uten navn-fane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aba Sem Título" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บไม่มีชื่อ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Adsız Sekme" + } + } + } + }, + "theme.dark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダーク" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dunkel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Oscuro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sombre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scuro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mørk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ciemny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Темная" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tamna" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "داكن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Mørk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escuro" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "มืด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Koyu" + } + } + } + }, + "theme.light": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "淺色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이트" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hell" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Claro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clair" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiaro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lys" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jasny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Светлая" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Svijetla" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فاتح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lys" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Claro" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สว่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açık" + } + } + } + }, + "theme.system": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Systemowy" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системная" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sistemski" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النظام" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระบบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem" + } + } + } + }, + "titlebar.newWorkspace.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Çalışma Alanı" + } + } + } + }, + "titlebar.newWorkspace.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova área de trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni çalışma alanı" + } + } + } + }, + "titlebar.notifications.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimler" + } + } + } + }, + "titlebar.notifications.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 표시" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar notificaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les notifications" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra notifiche" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis notifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż powiadomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать уведомления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži obavještenja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الإشعارات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis varsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar notificações" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงการแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimleri göster" + } + } + } + }, + "titlebar.sidebar.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーの切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换侧边栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換側邊欄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 전환" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste ein-/ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher/masquer la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå sidebjælke til/fra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz pasek boczny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Боковая панель" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci bočnu traku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå sidepanelet av/på" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğunu Aç/Kapat" + } + } + } + }, + "titlebar.sidebar.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show or hide the sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーの表示/非表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示或隐藏侧边栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示或隱藏側邊欄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 표시 또는 숨기기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste ein- oder ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar u ocultar la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher ou masquer la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra o nascondi la barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis eller skjul sidebjælken" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż lub ukryj pasek boczny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать или скрыть боковую панель" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži ili sakrij bočnu traku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار أو إخفاء الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis eller skjul sidepanelet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar ou ocultar a barra lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงหรือซ่อนแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar çubuğunu göster veya gizle" + } + } + } + }, + "update.available.short": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートがあります" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有可用更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "有可用的更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 사용 가능" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update verfügbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualización disponible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour disponible" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiornamento disponibile" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdatering tilgængelig" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dostępna aktualizacja" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступно обновление" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažuriranje dostupno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث متوفر" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdatering tilgjengelig" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualização Disponível" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "มีอัปเดตใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Mevcut" + } + } + } + }, + "update.available.withVersion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Available: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートあり: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有可用更新:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "有可用的更新:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 사용 가능: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update verfügbar: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualización disponible: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour disponible : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiornamento disponibile: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdatering tilgængelig: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dostępna aktualizacja: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступно обновление: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažuriranje dostupno: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث متوفر: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdatering tilgjengelig: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualização Disponível: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "มีอัปเดตใหม่: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Mevcut: %@" + } + } + } + }, + "update.checking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking for Updates…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを確認中…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查更新..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在檢查更新..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 확인 중…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche nach Updates …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscando actualizaciones…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recherche de mises à jour..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Verifica aggiornamenti in corso…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søger efter opdateringer…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdzanie aktualizacji…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка обновлений..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Provjeravanje ažuriranja…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التحقق من التحديثات…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ser etter oppdateringer …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificando Atualizações…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังตรวจหาอัปเดต..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeler denetleniyor…" + } + } + } + }, + "update.configureAutoUpdates": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Configure automatic update preferences" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動アップデートの設定" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置自动更新偏好设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定自動更新偏好" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 업데이트 환경설정 구성" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen für automatische Updates konfigurieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configurar preferencias de actualización automática" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configurer les préférences de mise à jour automatique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Configura le preferenze di aggiornamento automatico" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Konfigurer præferencer for automatiske opdateringer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Skonfiguruj preferencje automatycznych aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настроить параметры автоматических обновлений" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Konfigurišite postavke automatskog ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تكوين تفضيلات التحديث التلقائي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Konfigurer innstillinger for automatiske oppdateringer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Configurar preferências de atualização automática" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำหนดค่าการอัปเดตอัตโนมัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik güncelleme tercihlerini yapılandır" + } + } + } + }, + "update.downloadAndInstall": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download and install the latest version" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新バージョンをダウンロードしてインストール" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载并安装最新版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載並安裝最新版本" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신 버전 다운로드 및 설치" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neueste Version herunterladen und installieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargar e instalar la última versión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Télécharger et installer la dernière version" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scarica e installa l'ultima versione" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Download og installer den seneste version" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pobierz i zainstaluj najnowszą wersję" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузить и установить последнюю версию" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preuzmite i instalirajte najnoviju verziju" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنزيل وتثبيت أحدث إصدار" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Last ned og installer den nyeste versjonen" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Baixar e instalar a versão mais recente" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ดาวน์โหลดและติดตั้งเวอร์ชันล่าสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "En son sürümü indir ve yükle" + } + } + } + }, + "update.downloading.progress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード中: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在下載:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 중: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Herunterladen: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargando: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargement : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Download: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Downloader: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pobieranie: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preuzimanje: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التنزيل: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Laster ned: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Baixando: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังดาวน์โหลด: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İndiriliyor: %@" + } + } + } + }, + "update.downloading.status": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード中…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 중…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird heruntergeladen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargement..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Download in corso…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Downloader…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pobieranie…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preuzimanje…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التنزيل…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Laster ned …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Baixando…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังดาวน์โหลด..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İndiriliyor…" + } + } + } + }, + "update.downloadingPackage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading the update package" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートパッケージをダウンロード中" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载更新包" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在下載更新套件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 패키지 다운로드 중" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update-Paket wird heruntergeladen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargando el paquete de actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargement du paquet de mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Download del pacchetto di aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Downloader opdateringspakken" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pobieranie pakietu aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка пакета обновления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preuzimanje paketa ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تنزيل حزمة التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Laster ned oppdateringspakken" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Baixando o pacote de atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังดาวน์โหลดแพ็คเกจอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme paketi indiriliyor" + } + } + } + }, + "update.error.appLocation.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App Location Issue" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリの場所の問題" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用位置问题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "App 位置問題" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 위치 문제" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Problem mit dem App-Speicherort" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Problema con la ubicación de la app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Problème d'emplacement de l'app" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Problema posizione app" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Problem med appplacering" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Problem z lokalizacją aplikacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проблема с расположением приложения" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Problem s lokacijom aplikacije" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مشكلة في موقع التطبيق" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Problem med appplassering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Problema com Local do App" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปัญหาตำแหน่งแอป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uygulama Konumu Sorunu" + } + } + } + }, + "update.error.connectionLost.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The network connection was lost while checking for updates. Try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートの確認中にネットワーク接続が切れました。もう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查更新时网络连接中断。请重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查更新時網路連線中斷。請再試一次。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 확인 중 네트워크 연결이 끊어졌습니다. 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Netzwerkverbindung wurde beim Suchen nach Updates unterbrochen. Versuchen Sie es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se perdió la conexión de red al buscar actualizaciones. Inténtalo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La connexion réseau a été perdue lors de la recherche de mises à jour. Réessayez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La connessione di rete è stata persa durante la verifica degli aggiornamenti. Riprova." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Netværksforbindelsen gik tabt under søgning efter opdateringer. Prøv igen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połączenie sieciowe zostało utracone podczas sprawdzania aktualizacji. Spróbuj ponownie." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сетевое подключение было потеряно во время проверки обновлений. Повторите попытку." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Mrežna veza je prekinuta tokom provjere ažuriranja. Pokušajte ponovo." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقطع اتصال الشبكة أثناء التحقق من التحديثات. حاول مجددًا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettverkstilkoblingen ble mistet under søk etter oppdateringer. Prøv igjen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A conexão de rede foi perdida ao verificar atualizações. Tente novamente." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การเชื่อมต่อเครือข่ายขาดหายขณะตรวจหาอัปเดต ลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeler denetlenirken ağ bağlantısı kesildi. Tekrar deneyin." + } + } + } + }, + "update.error.connectionLost.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection Lost" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続が切れました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连接中断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連線中斷" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결 끊김" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung unterbrochen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conexión perdida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connexion perdue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connessione persa" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forbindelse tabt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Utracono połączenie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключение потеряно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Veza prekinuta" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقطع الاتصال" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilkobling mistet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Conexão Perdida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การเชื่อมต่อขาดหาย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlantı Kesildi" + } + } + } + }, + "update.error.downloadFailed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't Download Update" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートをダウンロードできませんでした" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法下载更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法下載更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트를 다운로드할 수 없습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update konnte nicht heruntergeladen werden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo descargar la actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de télécharger la mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile scaricare l'aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke downloade opdatering" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się pobrać aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось загрузить обновление" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće preuzeti ažuriranje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تنزيل التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke laste ned oppdatering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não Foi Possível Baixar a Atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถดาวน์โหลดอัปเดตได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme İndirilemedi" + } + } + } + }, + "update.error.failed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートに失敗しました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新失敗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 실패" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update fehlgeschlagen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiornamento non riuscito" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdatering mislykkedes" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Aktualizacja nie powiodła się" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка обновления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažuriranje nije uspjelo" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdatering mislyktes" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha na Atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การอัปเดตล้มเหลว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Başarısız" + } + } + } + }, + "update.error.feedDownload.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux couldn't download the update feed. Check your connection and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はアップデートフィードをダウンロードできませんでした。接続を確認してもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法下载更新源。请检查网络连接后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法下載更新來源。請檢查您的連線後再試一次。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux에서 업데이트 피드를 다운로드할 수 없습니다. 연결을 확인하고 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte den Update-Feed nicht herunterladen. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo descargar la fuente de actualizaciones. Comprueba tu conexión e inténtalo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n'a pas pu télécharger le flux de mises à jour. Vérifiez votre connexion et réessayez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non è riuscito a scaricare il feed degli aggiornamenti. Controlla la connessione e riprova." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke downloade opdateringsfeedet. Kontroller din forbindelse, og prøv igen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie mógł pobrać kanału aktualizacji. Sprawdź połączenie i spróbuj ponownie." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не удалось загрузить канал обновлений. Проверьте подключение и повторите попытку." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux nije mogao preuzeti feed ažuriranja. Provjerite vezu i pokušajte ponovo." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر على cmux تنزيل موجز التحديثات. تحقق من اتصالك وحاول مجددًا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke laste ned oppdateringsfeeden. Kontroller tilkoblingen og prøv igjen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não conseguiu baixar o feed de atualização. Verifique sua conexão e tente novamente." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ไม่สามารถดาวน์โหลดฟีดอัปเดตได้ ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux güncelleme akışını indiremedi. Bağlantınızı kontrol edip tekrar deneyin." + } + } + } + }, + "update.error.feedError.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Feed Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートフィードエラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新源错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新來源錯誤" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 피드 오류" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler im Update-Feed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error en la fuente de actualizaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur du flux de mises à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Errore feed aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fejl i opdateringsfeed" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Błąd kanału aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка канала обновлений" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Greška feeda ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ في موجز التحديثات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Feil i oppdateringsfeed" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Erro no Feed de Atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อผิดพลาดฟีดอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Akışı Hatası" + } + } + } + }, + "update.error.feedRead.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The update feed could not be read. Please try again later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートフィードを読み取れませんでした。後でもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取更新源。请稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法讀取更新來源。請稍後再試。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 피드를 읽을 수 없습니다. 나중에 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Update-Feed konnte nicht gelesen werden. Bitte versuchen Sie es später erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer la fuente de actualizaciones. Inténtalo de nuevo más tarde." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le flux de mises à jour n'a pas pu être lu. Veuillez réessayer plus tard." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile leggere il feed degli aggiornamenti. Riprova più tardi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateringsfeedet kunne ikke læses. Prøv igen senere." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie można odczytać kanału aktualizacji. Spróbuj ponownie później." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось прочитать канал обновлений. Повторите попытку позже." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Feed ažuriranja nije moguće pročitati. Pokušajte ponovo kasnije." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر قراءة موجز التحديثات. يرجى المحاولة لاحقًا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdateringsfeeden kunne ikke leses. Prøv igjen senere." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O feed de atualização não pôde ser lido. Tente novamente mais tarde." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถอ่านฟีดอัปเดตได้ กรุณาลองอีกครั้งในภายหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme akışı okunamadı. Lütfen daha sonra tekrar deneyin." + } + } + } + }, + "update.error.insecureFeed.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The update feed is insecure. Please contact support." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートフィードが安全ではありません。サポートにお問い合わせください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新源不安全。请联系支持团队。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新來源不安全。請聯絡支援團隊。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 피드가 안전하지 않습니다. 지원팀에 문의하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Update-Feed ist unsicher. Bitte kontaktieren Sie den Support." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La fuente de actualizaciones no es segura. Contacta con soporte." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le flux de mises à jour n'est pas sécurisé. Veuillez contacter le support." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il feed degli aggiornamenti non è sicuro. Contatta il supporto." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateringsfeedet er usikkert. Kontakt venligst supporten." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kanał aktualizacji nie jest bezpieczny. Skontaktuj się ze wsparciem." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Канал обновлений не защищен. Обратитесь в службу поддержки." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Feed ažuriranja nije siguran. Kontaktirajte podršku." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "موجز التحديثات غير آمن. يرجى الاتصال بالدعم." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdateringsfeeden er usikker. Kontakt brukerstøtte." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O feed de atualização é inseguro. Entre em contato com o suporte." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ฟีดอัปเดตไม่ปลอดภัย กรุณาติดต่อฝ่ายสนับสนุน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme akışı güvensiz. Lütfen destekle iletişime geçin." + } + } + } + }, + "update.error.insecureFeed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Insecure Update Feed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "安全でないアップデートフィード" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不安全的更新源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不安全的更新來源" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "안전하지 않은 업데이트 피드" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unsicherer Update-Feed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuente de actualizaciones insegura" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Flux de mises à jour non sécurisé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Feed aggiornamento non sicuro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Usikkert opdateringsfeed" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Niezabezpieczony kanał aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Незащищенный канал обновлений" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nesiguran feed ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "موجز تحديثات غير آمن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Usikker oppdateringsfeed" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Feed de Atualização Inseguro" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ฟีดอัปเดตไม่ปลอดภัย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güvensiz Güncelleme Akışı" + } + } + } + }, + "update.error.invalidFeed.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The update feed URL is invalid. Please contact support." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートフィードのURLが無効です。サポートにお問い合わせください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新源 URL 无效。请联系支持团队。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新來源 URL 無效。請聯絡支援團隊。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 피드 URL이 유효하지 않습니다. 지원팀에 문의하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die URL des Update-Feeds ist ungültig. Bitte kontaktieren Sie den Support." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La URL de la fuente de actualizaciones no es válida. Contacta con soporte." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'URL du flux de mises à jour n'est pas valide. Veuillez contacter le support." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L'URL del feed degli aggiornamenti non è valido. Contatta il supporto." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "URL'en til opdateringsfeedet er ugyldig. Kontakt venligst supporten." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Adres URL kanału aktualizacji jest nieprawidłowy. Skontaktuj się ze wsparciem." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "URL канала обновлений недействителен. Обратитесь в службу поддержки." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "URL feeda ažuriranja je nevažeći. Kontaktirajte podršku." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عنوان URL لموجز التحديثات غير صالح. يرجى الاتصال بالدعم." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "URL-en for oppdateringsfeeden er ugyldig. Kontakt brukerstøtte." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A URL do feed de atualização é inválida. Entre em contato com o suporte." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "URL ฟีดอัปเดตไม่ถูกต้อง กรุณาติดต่อฝ่ายสนับสนุน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme akışı URL'si geçersiz. Lütfen destekle iletişime geçin." + } + } + } + }, + "update.error.invalidFeed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid Update Feed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効なアップデートフィード" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效的更新源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無效的更新來源" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유효하지 않은 업데이트 피드" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültiger Update-Feed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuente de actualizaciones no válida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Flux de mises à jour non valide" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Feed aggiornamento non valido" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ugyldigt opdateringsfeed" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieprawidłowy kanał aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недействительный канал обновлений" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nevažeći feed ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "موجز تحديثات غير صالح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ugyldig oppdateringsfeed" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Feed de Atualização Inválido" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ฟีดอัปเดตไม่ถูกต้อง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçersiz Güncelleme Akışı" + } + } + } + }, + "update.error.noInternet.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux can't reach the update server. Check your internet connection and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はアップデートサーバーに接続できません。インターネット接続を確認してもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法连接到更新服务器。请检查互联网连接后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法連線至更新伺服器。請檢查您的網際網路連線後再試一次。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux에서 업데이트 서버에 연결할 수 없습니다. 인터넷 연결을 확인하고 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux kann den Update-Server nicht erreichen. Überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no puede acceder al servidor de actualizaciones. Comprueba tu conexión a internet e inténtalo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux ne peut pas atteindre le serveur de mises à jour. Vérifiez votre connexion Internet et réessayez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non riesce a raggiungere il server degli aggiornamenti. Controlla la connessione a internet e riprova." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kan ikke nå opdateringsserveren. Kontroller din internetforbindelse, og prøv igen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie może połączyć się z serwerem aktualizacji. Sprawdź połączenie z internetem i spróbuj ponownie." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не может связаться с сервером обновлений. Проверьте подключение к интернету и повторите попытку." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux ne može dosegnuti server za ažuriranje. Provjerite internetsku vezu i pokušajte ponovo." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يمكن لـ cmux الوصول إلى خادم التحديثات. تحقق من اتصال الإنترنت وحاول مجددًا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kan ikke nå oppdateringsserveren. Kontroller internettforbindelsen og prøv igjen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não consegue acessar o servidor de atualização. Verifique sua conexão com a internet e tente novamente." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ไม่สามารถเข้าถึงเซิร์ฟเวอร์อัปเดตได้ ตรวจสอบการเชื่อมต่ออินเทอร์เน็ตแล้วลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux güncelleme sunucusuna ulaşamıyor. İnternet bağlantınızı kontrol edip tekrar deneyin." + } + } + } + }, + "update.error.noInternet.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Internet Connection" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インターネット接続がありません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无互联网连接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有網際網路連線" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인터넷 연결 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Internetverbindung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin conexión a internet" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune connexion Internet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna connessione a internet" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen internetforbindelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak połączenia z internetem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет подключения к интернету" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nema internetske veze" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد اتصال بالإنترنت" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen internettforbindelse" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sem Conexão com a Internet" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีการเชื่อมต่ออินเทอร์เน็ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İnternet Bağlantısı Yok" + } + } + } + }, + "update.error.permissionError.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move cmux into Applications and relaunch to enable updates." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを有効にするには、cmux を「アプリケーション」に移動して再起動してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请将 cmux 移到「应用程序」文件夹中,然后重新启动以启用更新。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請將 cmux 移至「應用程式」資料夾並重新啟動,以啟用更新。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux를 응용 프로그램 폴더로 이동하고 재실행하여 업데이트를 활성화하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verschieben Sie cmux in den Ordner 'Programme' und starten Sie die App neu, um Updates zu aktivieren." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mueve cmux a Aplicaciones y vuelve a iniciarlo para activar las actualizaciones." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacez cmux dans Applications et relancez pour activer les mises à jour." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta cmux nella cartella Applicazioni e riavvia per abilitare gli aggiornamenti." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Flyt cmux til Programmer, og genstart for at aktivere opdateringer." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przenieś cmux do folderu Aplikacje i uruchom ponownie, aby włączyć aktualizacje." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместите cmux в папку «Программы» и перезапустите для включения обновлений." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Premjestite cmux u Applications i ponovo pokrenite da biste omogućili ažuriranja." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقل cmux إلى مجلد التطبيقات وأعد التشغيل لتفعيل التحديثات." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Flytt cmux til Programmer og start på nytt for å aktivere oppdateringer." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mova o cmux para Aplicativos e reinicie para ativar as atualizações." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ย้าย cmux ไปที่โฟลเดอร์ Applications แล้วเปิดใหม่เพื่อเปิดใช้งานการอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeleri etkinleştirmek için cmux'u Uygulamalar klasörüne taşıyıp yeniden başlatın." + } + } + } + }, + "update.error.permissionError.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updater Permission Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデーター権限エラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新程序权限错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新程式權限錯誤" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이터 권한 오류" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungsfehler des Updaters" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de permisos del actualizador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur de permissions du programme de mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Errore di autorizzazione dell'aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fejl i opdateringstilladelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Błąd uprawnień aktualizatora" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка прав доступа программы обновления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Greška dozvole za ažuriranje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ في صلاحيات المحدّث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tillatelsessfeil for oppdatering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Erro de Permissão do Atualizador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อผิดพลาดสิทธิ์ตัวอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleyici İzin Hatası" + } + } + } + }, + "update.error.secureConnectionFailed.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A secure connection to the update server couldn't be established. Try again later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートサーバーへのセキュア接続を確立できませんでした。後でもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法与更新服务器建立安全连接。请稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法建立與更新伺服器的安全連線。請稍後再試。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 서버에 대한 보안 연결을 설정할 수 없습니다. 나중에 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es konnte keine sichere Verbindung zum Update-Server hergestellt werden. Versuchen Sie es später erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo establecer una conexión segura con el servidor de actualizaciones. Inténtalo de nuevo más tarde." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'établir une connexion sécurisée avec le serveur de mises à jour. Réessayez plus tard." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile stabilire una connessione sicura al server degli aggiornamenti. Riprova più tardi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "En sikker forbindelse til opdateringsserveren kunne ikke oprettes. Prøv igen senere." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się nawiązać bezpiecznego połączenia z serwerem aktualizacji. Spróbuj ponownie później." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось установить защищенное подключение к серверу обновлений. Повторите попытку позже." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće uspostaviti sigurnu vezu sa serverom za ažuriranje. Pokušajte ponovo kasnije." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر إنشاء اتصال آمن بخادم التحديثات. حاول لاحقًا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "En sikker tilkobling til oppdateringsserveren kunne ikke opprettes. Prøv igjen senere." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível estabelecer uma conexão segura com o servidor de atualização. Tente novamente mais tarde." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถสร้างการเชื่อมต่อที่ปลอดภัยกับเซิร์ฟเวอร์อัปเดตได้ ลองอีกครั้งในภายหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme sunucusuyla güvenli bir bağlantı kurulamadı. Daha sonra tekrar deneyin." + } + } + } + }, + "update.error.secureConnectionFailed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Secure Connection Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セキュア接続に失敗しました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安全连接失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安全連線失敗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보안 연결 실패" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sichere Verbindung fehlgeschlagen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de conexión segura" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la connexion sécurisée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connessione sicura non riuscita" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sikker forbindelse mislykkedes" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Bezpieczne połączenie nie powiodło się" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка защищенного подключения" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sigurna veza nije uspjela" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الاتصال الآمن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sikker tilkobling mislyktes" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha na Conexão Segura" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การเชื่อมต่อที่ปลอดภัยล้มเหลว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güvenli Bağlantı Başarısız" + } + } + } + }, + "update.error.serverNotFound.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The update server can't be found. Check your connection or try again later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートサーバーが見つかりません。接続を確認するか、後でもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找不到更新服务器。请检查网络连接或稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到更新伺服器。請檢查您的連線或稍後再試。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 서버를 찾을 수 없습니다. 연결을 확인하거나 나중에 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Update-Server kann nicht gefunden werden. Überprüfen Sie Ihre Verbindung oder versuchen Sie es später erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encuentra el servidor de actualizaciones. Comprueba tu conexión o inténtalo de nuevo más tarde." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le serveur de mises à jour est introuvable. Vérifiez votre connexion ou réessayez plus tard." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile trovare il server degli aggiornamenti. Controlla la connessione o riprova più tardi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateringsserveren kan ikke findes. Kontroller din forbindelse, eller prøv igen senere." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie można znaleźć serwera aktualizacji. Sprawdź połączenie lub spróbuj ponownie później." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сервер обновлений не найден. Проверьте подключение или повторите попытку позже." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Server za ažuriranje nije pronađen. Provjerite vezu ili pokušajte ponovo kasnije." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر العثور على خادم التحديثات. تحقق من اتصالك أو حاول لاحقًا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdateringsserveren ble ikke funnet. Kontroller tilkoblingen eller prøv igjen senere." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O servidor de atualização não foi encontrado. Verifique sua conexão ou tente novamente mais tarde." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบเซิร์ฟเวอร์อัปเดต ตรวจสอบการเชื่อมต่อหรือลองอีกครั้งในภายหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme sunucusu bulunamıyor. Bağlantınızı kontrol edin veya daha sonra tekrar deneyin." + } + } + } + }, + "update.error.serverNotFound.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server Not Found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーバーが見つかりません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找不到服务器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到伺服器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서버를 찾을 수 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Server nicht gefunden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Servidor no encontrado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Serveur introuvable" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Server non trovato" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Server ikke fundet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono serwera" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сервер не найден" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Server nije pronađen" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على الخادم" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Server ikke funnet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Servidor Não Encontrado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบเซิร์ฟเวอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sunucu Bulunamadı" + } + } + } + }, + "update.error.serverUnreachable.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux couldn't connect to the update server. Check your connection or try again later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はアップデートサーバーに接続できませんでした。接続を確認するか、後でもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法连接到更新服务器。请检查网络连接或稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法連線至更新伺服器。請檢查您的連線或稍後再試。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux에서 업데이트 서버에 연결할 수 없습니다. 연결을 확인하거나 나중에 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte keine Verbindung zum Update-Server herstellen. Überprüfen Sie Ihre Verbindung oder versuchen Sie es später erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo conectar con el servidor de actualizaciones. Comprueba tu conexión o inténtalo de nuevo más tarde." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n'a pas pu se connecter au serveur de mises à jour. Vérifiez votre connexion ou réessayez plus tard." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non è riuscito a connettersi al server degli aggiornamenti. Controlla la connessione o riprova più tardi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke oprette forbindelse til opdateringsserveren. Kontroller din forbindelse, eller prøv igen senere." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie mógł połączyć się z serwerem aktualizacji. Sprawdź połączenie lub spróbuj ponownie później." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не удалось подключиться к серверу обновлений. Проверьте подключение или повторите попытку позже." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux se nije mogao povezati na server za ažuriranje. Provjerite vezu ili pokušajte ponovo kasnije." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر على cmux الاتصال بخادم التحديثات. تحقق من اتصالك أو حاول لاحقًا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke koble til oppdateringsserveren. Kontroller tilkoblingen eller prøv igjen senere." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não conseguiu se conectar ao servidor de atualização. Verifique sua conexão ou tente novamente mais tarde." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์อัปเดตได้ ตรวจสอบการเชื่อมต่อหรือลองอีกครั้งในภายหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux güncelleme sunucusuna bağlanamadı. Bağlantınızı kontrol edin veya daha sonra tekrar deneyin." + } + } + } + }, + "update.error.serverUnreachable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server Unreachable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーバーに接続できません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "服务器不可达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "伺服器無法連線" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서버에 연결할 수 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Server nicht erreichbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Servidor inaccesible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Serveur inaccessible" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Server non raggiungibile" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Server utilgængelig" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Serwer nieosiągalny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сервер недоступен" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Server nedostupan" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الخادم غير قابل للوصول" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Server utilgjengelig" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Servidor Inacessível" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซิร์ฟเวอร์เข้าถึงไม่ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sunucuya Erişilemiyor" + } + } + } + }, + "update.error.signatureError.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The update's signature could not be verified. Please try again later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートの署名を検証できませんでした。後でもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法验证更新的签名。请稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法驗證更新的簽章。請稍後再試。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 서명을 확인할 수 없습니다. 나중에 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Signatur des Updates konnte nicht überprüft werden. Bitte versuchen Sie es später erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo verificar la firma de la actualización. Inténtalo de nuevo más tarde." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La signature de la mise à jour n'a pas pu être vérifiée. Veuillez réessayer plus tard." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile verificare la firma dell'aggiornamento. Riprova più tardi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateringens signatur kunne ikke verificeres. Prøv igen senere." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się zweryfikować podpisu aktualizacji. Spróbuj ponownie później." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось проверить подпись обновления. Повторите попытку позже." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Potpis ažuriranja nije mogao biti verificiran. Pokušajte ponovo kasnije." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر التحقق من توقيع التحديث. يرجى المحاولة لاحقًا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdateringens signatur kunne ikke verifiseres. Prøv igjen senere." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A assinatura da atualização não pôde ser verificada. Tente novamente mais tarde." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถยืนยันลายเซ็นของอัปเดตได้ กรุณาลองอีกครั้งในภายหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemenin imzası doğrulanamadı. Lütfen daha sonra tekrar deneyin." + } + } + } + }, + "update.error.signatureError.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Signature Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデート署名エラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新签名错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新簽章錯誤" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 서명 오류" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler bei der Update-Signatur" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de firma de actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur de signature de la mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Errore firma aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fejl i opdateringssignatur" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Błąd podpisu aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка подписи обновления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Greška potpisa ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ في توقيع التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Feil i oppdateringssignatur" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Erro de Assinatura da Atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อผิดพลาดลายเซ็นอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme İmza Hatası" + } + } + } + }, + "update.error.timedOut.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The update server took too long to respond. Try again in a moment." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートサーバーの応答に時間がかかりすぎました。しばらくしてからもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新服务器响应超时。请稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新伺服器回應時間過長。請稍後再試。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 서버가 응답하는 데 너무 오래 걸렸습니다. 잠시 후 다시 시도하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Update-Server hat zu lange gebraucht, um zu antworten. Versuchen Sie es in einem Moment erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El servidor de actualizaciones tardó demasiado en responder. Inténtalo de nuevo en un momento." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le serveur de mises à jour a mis trop de temps à répondre. Réessayez dans un instant." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il server degli aggiornamenti ha impiegato troppo tempo a rispondere. Riprova tra un momento." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateringsserveren var for lang tid om at svare. Prøv igen om et øjeblik." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Serwer aktualizacji zbyt długo nie odpowiadał. Spróbuj ponownie za chwilę." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сервер обновлений не ответил вовремя. Повторите попытку через некоторое время." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Server za ažuriranje je predugo čekao da odgovori. Pokušajte ponovo za trenutak." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "استغرق خادم التحديثات وقتًا طويلاً للاستجابة. حاول مجددًا بعد لحظة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdateringsserveren brukte for lang tid på å svare. Prøv igjen om et øyeblikk." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O servidor de atualização demorou muito para responder. Tente novamente em instantes." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซิร์ฟเวอร์อัปเดตใช้เวลานานเกินไปในการตอบกลับ ลองอีกครั้งในอีกสักครู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme sunucusu yanıt vermekte çok uzun sürdü. Birazdan tekrar deneyin." + } + } + } + }, + "update.error.timedOut.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Timed Out" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートがタイムアウトしました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新超时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新逾時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 시간 초과" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update-Zeitüberschreitung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tiempo de espera agotado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Délai de mise à jour dépassé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiornamento scaduto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdatering fik timeout" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przekroczono limit czasu aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Время ожидания обновления истекло" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Isteklo vrijeme ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت مهلة التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdatering tidsavbrutt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tempo de Atualização Esgotado" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การอัปเดตหมดเวลา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Zaman Aşımına Uğradı" + } + } + } + }, + "update.extracting.progress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "準備中: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在准备:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在準備:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "준비 중: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorbereitung: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preparando: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préparation : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Preparazione: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forbereder: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przygotowywanie: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подготовка: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Priprema: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التحضير: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forbereder: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Preparando: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังเตรียม: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hazırlanıyor: %@" + } + } + } + }, + "update.installAndRelaunch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install Update and Relaunch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートをインストールして再起動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装更新并重新启动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝更新並重新啟動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 설치 후 재실행" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update installieren und neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalar actualización y reiniciar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installer la mise à jour et relancer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Installa aggiornamento e riavvia" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Installer opdatering og genstart" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zainstaluj aktualizację i uruchom ponownie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установить обновление и перезапустить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Instaliraj ažuriranje i ponovo pokreni" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت التحديث وإعادة التشغيل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Installer oppdatering og start på nytt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Instalar Atualização e Reiniciar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ติดตั้งอัปเดตและเปิดใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeyi Yükle ve Yeniden Başlat" + } + } + } + }, + "update.installing.status": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Installing…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール中…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在安装..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치 중…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird installiert …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installation..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Installazione in corso…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Installerer…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Instalowanie…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установка..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Instaliranje…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التثبيت…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Installerer …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Instalando…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังติดตั้ง..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yükleniyor…" + } + } + } + }, + "update.installingAndRestarting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Installing update and preparing to restart" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートをインストールして再起動を準備中" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在安装更新并准备重启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在安裝更新並準備重新啟動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 설치 및 재시작 준비 중" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update wird installiert und Neustart wird vorbereitet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalando la actualización y preparando el reinicio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installation de la mise à jour et préparation du redémarrage" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Installazione dell'aggiornamento e preparazione al riavvio" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Installerer opdatering og forbereder genstart" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Instalowanie aktualizacji i przygotowywanie do ponownego uruchomienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установка обновления и подготовка к перезапуску" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Instaliranje ažuriranja i priprema za ponovni start" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تثبيت التحديث والتحضير لإعادة التشغيل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Installerer oppdatering og forbereder omstart" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Instalando a atualização e preparando para reiniciar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังติดตั้งอัปเดตและเตรียมรีสตาร์ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme yükleniyor ve yeniden başlatmaya hazırlanıyor" + } + } + } + }, + "update.noUpdates.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You are running the latest version" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新バージョンを使用しています" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "您正在运行最新版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "您正在執行最新版本" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신 버전을 사용 중입니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sie verwenden die neueste Version" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estás ejecutando la última versión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous utilisez la dernière version" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Stai utilizzando l'ultima versione" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Du kører den seneste version" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Używasz najnowszej wersji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "У вас установлена последняя версия" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Koristite najnoviju verziju" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أنت تستخدم أحدث إصدار" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Du kjører den nyeste versjonen" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Você está usando a versão mais recente" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คุณใช้เวอร์ชันล่าสุดแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "En son sürümü kullanıyorsunuz" + } + } + } + }, + "update.noUpdates.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Updates Available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートはありません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有可用更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有可用的更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 가능한 업데이트 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Updates verfügbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay actualizaciones disponibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune mise à jour disponible" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun aggiornamento disponibile" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen tilgængelige opdateringer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak dostępnych aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновлений нет" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nema dostupnih ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد تحديثات متوفرة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen oppdateringer tilgjengelig" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma Atualização Disponível" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีอัปเดตใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Yok" + } + } + } + }, + "update.permissionRequest.text": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Automatic Updates?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動アップデートを有効にしますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用自动更新?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要啟用自動更新嗎?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 업데이트를 활성화하시겠습니까?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatische Updates aktivieren?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Activar actualizaciones automáticas?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer les mises à jour automatiques ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Abilitare gli aggiornamenti automatici?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Aktiver automatiske opdateringer?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Włączyć automatyczne aktualizacje?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить автоматические обновления?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Omogućiti automatska ažuriranja?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفعيل التحديثات التلقائية؟" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktivere automatiske oppdateringer?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ativar Atualizações Automáticas?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดใช้งานการอัปเดตอัตโนมัติหรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik Güncellemeler Etkinleştirilsin mi?" + } + } + } + }, + "update.pleaseWait": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please wait while we check for available updates" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを確認しています。しばらくお待ちください" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查可用更新,请稍候" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請稍候,正在檢查可用的更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 가능한 업데이트를 확인하는 동안 잠시 기다려주세요" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bitte warten Sie, während nach verfügbaren Updates gesucht wird" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espera mientras buscamos actualizaciones disponibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Veuillez patienter pendant la recherche de mises à jour disponibles" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attendi mentre verifichiamo gli aggiornamenti disponibili" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vent venligst, mens vi søger efter tilgængelige opdateringer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Proszę czekać, sprawdzamy dostępne aktualizacje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подождите, пока мы проверяем наличие обновлений" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pričekajte dok provjeravamo dostupna ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يرجى الانتظار أثناء التحقق من التحديثات المتوفرة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vennligst vent mens vi ser etter tilgjengelige oppdateringer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aguarde enquanto verificamos as atualizações disponíveis" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กรุณารอขณะตรวจหาอัปเดตที่มีอยู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Mevcut güncellemeler denetlenirken lütfen bekleyin" + } + } + } + }, + "update.popover.autoUpdatesDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux can automatically check for updates in the background." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はバックグラウンドで自動的にアップデートを確認できます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 可以在后台自动检查更新。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 可以在背景自動檢查更新。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 백그라운드에서 자동으로 업데이트를 확인할 수 있습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux kann automatisch im Hintergrund nach Updates suchen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux puede buscar actualizaciones automáticamente en segundo plano." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux peut rechercher automatiquement les mises à jour en arrière-plan." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux può verificare automaticamente la disponibilità di aggiornamenti in background." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kan automatisk søge efter opdateringer i baggrunden." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux może automatycznie sprawdzać aktualizacje w tle." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux может автоматически проверять наличие обновлений в фоновом режиме." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux može automatski provjeravati ažuriranja u pozadini." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يمكن لـ cmux التحقق تلقائيًا من التحديثات في الخلفية." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kan automatisk se etter oppdateringer i bakgrunnen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux pode verificar automaticamente se há atualizações em segundo plano." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux สามารถตรวจหาอัปเดตอัตโนมัติในเบื้องหลังได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux arka planda otomatik olarak güncellemeleri denetleyebilir." + } + } + } + }, + "update.popover.checking": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking for updates…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを確認中…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查更新..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在檢查更新..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 확인 중…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche nach Updates …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscando actualizaciones…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recherche de mises à jour..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Verifica aggiornamenti in corso…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søger efter opdateringer…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdzanie aktualizacji…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка обновлений..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Provjeravanje ažuriranja…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التحقق من التحديثات…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ser etter oppdateringer …" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificando atualizações…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังตรวจหาอัปเดต..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeler denetleniyor…" + } + } + } + }, + "update.popover.details": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详细信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "詳細資訊" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부 정보" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Details" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detalles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Détails" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dettagli" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Detaljer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szczegóły" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подробности" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Detalji" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التفاصيل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Detaljer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Detalhes" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รายละเอียด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayrıntılar" + } + } + } + }, + "update.popover.downloadingUpdate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading Update" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートをダウンロード中" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在下載更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 다운로드 중" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update wird heruntergeladen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargando actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargement de la mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Download aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Downloader opdatering" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pobieranie aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка обновления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Preuzimanje ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تنزيل التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Laster ned oppdatering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Baixando Atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังดาวน์โหลดอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme İndiriliyor" + } + } + } + }, + "update.popover.enableAutoUpdates": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable automatic updates?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動アップデートを有効にしますか?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用自动更新?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要啟用自動更新嗎?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 업데이트를 활성화하시겠습니까?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatische Updates aktivieren?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Activar actualizaciones automáticas?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer les mises à jour automatiques ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Abilitare gli aggiornamenti automatici?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Aktiver automatiske opdateringer?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Włączyć automatyczne aktualizacje?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить автоматические обновления?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Omogućiti automatska ažuriranja?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفعيل التحديثات التلقائية؟" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktivere automatiske oppdateringer?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ativar atualizações automáticas?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดใช้งานการอัปเดตอัตโนมัติหรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik güncellemeler etkinleştirilsin mi?" + } + } + } + }, + "update.popover.noUpdatesFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Updates Found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートは見つかりませんでした" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未发现更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有找到更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Updates gefunden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontraron actualizaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune mise à jour trouvée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun aggiornamento trovato" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen opdateringer fundet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновлений не найдено" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažuriranja nisu pronađena" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على تحديثات" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen oppdateringer funnet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma Atualização Encontrada" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Bulunamadı" + } + } + } + }, + "update.popover.noUpdatesFound.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You're already running the latest version." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すでに最新バージョンを使用しています。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "您已经在运行最新版本。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "您已經在使用最新版本。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미 최신 버전을 사용 중입니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sie verwenden bereits die neueste Version." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ya estás ejecutando la última versión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous utilisez déjà la dernière version." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Stai già utilizzando l'ultima versione." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Du kører allerede den seneste version." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Używasz już najnowszej wersji." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "У вас уже установлена последняя версия." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Već koristite najnoviju verziju." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أنت تستخدم أحدث إصدار بالفعل." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Du kjører allerede den nyeste versjonen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Você já está usando a versão mais recente." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คุณใช้เวอร์ชันล่าสุดอยู่แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Zaten en son sürümü kullanıyorsunuz." + } + } + } + }, + "update.popover.preparingUpdate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing Update" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを準備中" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在准备更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在準備更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 준비 중" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update wird vorbereitet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preparando actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préparation de la mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Preparazione aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forbereder opdatering" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przygotowywanie aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подготовка обновления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Priprema ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحضير التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forbereder oppdatering" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Preparando Atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังเตรียมอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Hazırlanıyor" + } + } + } + }, + "update.popover.released": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Released:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リリース日:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发布日期:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "發佈日期:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "출시일:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Veröffentlicht:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Publicado:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Publiée le :" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rilasciato:" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Udgivet:" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wydano:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выпущено:" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Objavljeno:" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تاريخ الإصدار:" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Utgitt:" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Lançado:" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เผยแพร่:" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yayınlanma:" + } + } + } + }, + "update.popover.restartRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart Required" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再起動が必要です" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要重启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要重新啟動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시작 필요" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neustart erforderlich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reinicio requerido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrage requis" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvio necessario" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart påkrævet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wymagane ponowne uruchomienie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется перезапуск" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Potreban ponovni start" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التشغيل مطلوبة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Omstart nødvendig" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reinicialização Necessária" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ต้องรีสตาร์ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden Başlatma Gerekli" + } + } + } + }, + "update.popover.restartRequired.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The update is ready. Please restart the application to complete the installation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートの準備ができました。インストールを完了するにはアプリケーションを再起動してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新已就绪。请重启应用程序以完成安装。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新已就緒。請重新啟動應用程式以完成安裝。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트가 준비되었습니다. 설치를 완료하려면 앱을 재시작하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Update ist bereit. Bitte starten Sie die Anwendung neu, um die Installation abzuschließen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La actualización está lista. Reinicia la aplicación para completar la instalación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La mise à jour est prête. Veuillez redémarrer l'application pour terminer l'installation." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L'aggiornamento è pronto. Riavvia l'applicazione per completare l'installazione." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateringen er klar. Genstart programmet for at fuldføre installationen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Aktualizacja jest gotowa. Uruchom ponownie aplikację, aby zakończyć instalację." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновление готово. Перезапустите приложение для завершения установки." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažuriranje je spremno. Ponovo pokrenite aplikaciju da završite instalaciju." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحديث جاهز. يرجى إعادة تشغيل التطبيق لإكمال التثبيت." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdateringen er klar. Start programmet på nytt for å fullføre installasjonen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A atualização está pronta. Reinicie o aplicativo para concluir a instalação." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อัปเดตพร้อมแล้ว กรุณารีสตาร์ตแอปพลิเคชันเพื่อเสร็จสิ้นการติดตั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme hazır. Yüklemeyi tamamlamak için lütfen uygulamayı yeniden başlatın." + } + } + } + }, + "update.popover.size": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Size:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイズ:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "大小:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "大小:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크기:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Größe:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tamaño:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille :" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dimensione:" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Størrelse:" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozmiar:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Размер:" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Veličina:" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحجم:" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Størrelse:" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tamanho:" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาด:" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Boyut:" + } + } + } + }, + "update.popover.updateAvailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートがあります" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有可用更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "有可用的更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 사용 가능" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update verfügbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualización disponible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour disponible" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiornamento disponibile" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdatering tilgængelig" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dostępna aktualizacja" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступно обновление" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažuriranje dostupno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث متوفر" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdatering tilgjengelig" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualização Disponível" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "มีอัปเดตใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme Mevcut" + } + } + } + }, + "update.popover.version": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョン:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "版本:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버전:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Version:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Versión:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Version :" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Versione:" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Version:" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wersja:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Версия:" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Verzija:" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإصدار:" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Versjon:" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Versão:" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวอร์ชัน:" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sürüm:" + } + } + } + }, + "update.preparingUpdate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extracting and preparing the update" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを展開して準備中" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在解压和准备更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在解壓並準備更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 추출 및 준비 중" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update wird entpackt und vorbereitet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Extrayendo y preparando la actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Extraction et préparation de la mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Estrazione e preparazione dell'aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Udpakker og forbereder opdateringen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozpakowywanie i przygotowywanie aktualizacji" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Извлечение и подготовка обновления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Izdvajanje i priprema ažuriranja" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ استخراج التحديث وتحضيره" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Pakker ut og forbereder oppdateringen" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Extraindo e preparando a atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังแตกไฟล์และเตรียมอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme çıkarılıyor ve hazırlanıyor" + } + } + } + }, + "update.restartToComplete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart to Complete Update" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを完了するには再起動してください" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启以完成更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動以完成更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트를 완료하려면 재시작" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neustart zum Abschließen des Updates" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar para completar la actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer pour terminer la mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia per completare l'aggiornamento" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart for at fuldføre opdatering" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie, aby zakończyć aktualizację" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустите для завершения обновления" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokrenite da završite ažuriranje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أعد التشغيل لإكمال التحديث" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start på nytt for å fullføre oppdateringen" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar para Concluir a Atualização" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ตเพื่อเสร็จสิ้นการอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemeyi Tamamlamak İçin Yeniden Başlat" + } + } + } + }, + "update.viewGitHubCommit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View GitHub Commit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GitHub コミットを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看 GitHub 提交" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢視 GitHub 提交" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "GitHub 커밋 보기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "GitHub-Commit anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver commit en GitHub" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Voir le commit GitHub" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Visualizza commit su GitHub" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis GitHub Commit" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż commit na GitHub" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просмотреть коммит на GitHub" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pogledaj GitHub commit" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض إيداع GitHub" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis GitHub-commit" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ver Commit no GitHub" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ดูคอมมิต GitHub" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "GitHub Commit'ini Görüntüle" + } + } + } + }, + "update.viewReleaseNotes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View Release Notes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リリースノートを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看发行说明" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢視版本說明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "릴리스 노트 보기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Versionshinweise anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver notas de la versión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Voir les notes de version" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Visualizza note di rilascio" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis udgivelsesnoter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż informacje o wydaniu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просмотреть заметки о выпуске" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pogledaj bilješke o izdanju" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض ملاحظات الإصدار" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis versjonsnotater" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ver Notas de Lançamento" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ดูบันทึกการเผยแพร่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sürüm Notlarını Görüntüle" + } + } + } + }, + "workspace.displayName.fallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de Trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma Alanı" + } + } + } + }, + "workspace.placement.afterCurrent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After current" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在の後" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前之后" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前之後" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 다음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach aktuellem" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Después del actual" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Après l'actuel" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dopo la corrente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Efter nuværende" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po bieżącej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "После текущего" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Poslije trenutnog" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بعد الحالي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Etter gjeldende" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Após a atual" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หลังรายการปัจจุบัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli sonrasına" + } + } + } + }, + "workspace.placement.afterCurrent.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Insert new workspaces directly after the active workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティブなワークスペースの直後に新しいワークスペースを挿入します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在活动工作区之后插入新工作区。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將新工作區插入目前使用中工作區的後方。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활성 작업 공간 바로 다음에 새 작업 공간을 삽입합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue Arbeitsbereiche direkt nach dem aktiven Arbeitsbereich einfügen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Insertar nuevos espacios de trabajo justo después del espacio de trabajo activo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Insérer les nouveaux espaces de travail juste après l'espace de travail actif." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci le nuove aree di lavoro subito dopo quella attiva." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indsæt nye arbejdsområder direkte efter det aktive arbejdsområde." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wstaw nowe przestrzenie robocze bezpośrednio po aktywnej." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вставлять новые рабочие пространства сразу после активного." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Umetnite nove radne prostore odmah nakon aktivnog radnog prostora." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدراج مساحات العمل الجديدة مباشرة بعد مساحة العمل النشطة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sett inn nye arbeidsområder rett etter det aktive arbeidsområdet." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Inserir novas áreas de trabalho diretamente após a área de trabalho ativa." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แทรกเวิร์กสเปซใหม่หลังเวิร์กสเปซที่ใช้งานอยู่โดยตรง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni çalışma alanlarını etkin çalışma alanının hemen sonrasına ekle." + } + } + } + }, + "workspace.placement.end": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "末尾" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "末尾" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "底部" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끝" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ende" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Final" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fin" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fine" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sidst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Na końcu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В конец" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kraj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النهاية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slutt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Final" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ท้ายสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sona" + } + } + } + }, + "workspace.placement.end.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Append new workspaces to the bottom of the list." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいワークスペースをリストの末尾に追加します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将新工作区添加到列表底部。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將新工作區附加到列表底部。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목록 하단에 새 작업 공간을 추가합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue Arbeitsbereiche am Ende der Liste anfügen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar nuevos espacios de trabajo al final de la lista." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter les nouveaux espaces de travail en bas de la liste." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiungi le nuove aree di lavoro in fondo alla lista." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilføj nye arbejdsområder nederst på listen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dodaj nowe przestrzenie robocze na dole listy." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавлять новые рабочие пространства в конец списка." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dodajte nove radne prostore na kraj liste." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلحاق مساحات العمل الجديدة في أسفل القائمة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Legg til nye arbeidsområder nederst i listen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Adicionar novas áreas de trabalho ao final da lista." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เพิ่มเวิร์กสเปซใหม่ที่ด้านล่างของรายการ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni çalışma alanlarını listenin sonuna ekle." + } + } + } + }, + "workspace.placement.top": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Top" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "先頭" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "顶部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最上方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "맨 위" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Début" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inizio" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Øverst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Na górze" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В начало" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vrh" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأعلى" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Topp" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Topo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ด้านบน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "En Üste" + } + } + } + }, + "workspace.placement.top.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Insert new workspaces at the top of the list." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいワークスペースをリストの先頭に挿入します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在列表顶部插入新工作区。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將新工作區插入列表最上方。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목록 맨 위에 새 작업 공간을 삽입합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue Arbeitsbereiche oben in der Liste einfügen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Insertar nuevos espacios de trabajo al inicio de la lista." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Insérer les nouveaux espaces de travail en haut de la liste." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci le nuove aree di lavoro in cima alla lista." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indsæt nye arbejdsområder øverst på listen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wstaw nowe przestrzenie robocze na górze listy." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вставлять новые рабочие пространства в начало списка." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Umetnite nove radne prostore na vrh liste." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدراج مساحات العمل الجديدة في أعلى القائمة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sett inn nye arbeidsområder øverst i listen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Inserir novas áreas de trabalho no topo da lista." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แทรกเวิร์กสเปซใหม่ที่ด้านบนของรายการ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni çalışma alanlarını listenin en üstüne ekle." + } + } + } + }, + "workspace.tooltip.newBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ブラウザ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增瀏覽器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 브라우저" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Browser" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouveau navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuovo browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ny browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa przeglądarka" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новый браузер" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi preglednik" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "متصفح جديد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ny nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Novo Navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เบราว์เซอร์ใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Tarayıcı" + } + } + } + }, + "workspace.tooltip.newTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ターミナル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建终端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增終端機" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 터미널" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neues Terminal" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouveau terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuovo terminale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ny terminal" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowy terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новый терминал" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi terminal" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "طرفية جديدة" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ny terminal" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Novo Terminal" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เทอร์มินัลใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Terminal" + } + } + } + }, + "workspace.tooltip.splitDown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向下拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向下分割" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach unten teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi in basso" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel nedad" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel w dół" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить вниз" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli dolje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم للأسفل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del ned" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir para Baixo" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกลงล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aşağı Böl" + } + } + } + }, + "workspace.tooltip.splitRight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右に分割" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右分割" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽으로 분할" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach rechts teilen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dividir a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diviser à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dividi a destra" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdel til højre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podziel w prawo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разделить вправо" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podijeli desno" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم لليمين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Del til høyre" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Dividir à Direita" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แยกไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sağa Böl" + } + } + } + }, + "markdown.fileUnavailable.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file may have been moved or deleted." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルが移動または削除された可能性があります。" + } + } + } + }, + "markdown.fileUnavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルを利用できません" + } + } + } + } + } +} diff --git a/Resources/bin/claude b/Resources/bin/claude index d722b9c7..02939248 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -18,8 +18,36 @@ find_real_claude() { return 1 } -# Pass through if not in a cmux terminal or hooks are disabled. -if [[ -z "$CMUX_SURFACE_ID" || "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]]; then +# Return 0 only when CMUX_SOCKET_PATH points to a live cmux socket. +cmux_socket_available() { + local socket="${CMUX_SOCKET_PATH:-}" + [[ -n "$socket" && -S "$socket" ]] || return 1 + + local self_dir cmux_bin + self_dir="$(cd "$(dirname "$0")" && pwd)" + cmux_bin="$self_dir/cmux" + [[ -x "$cmux_bin" ]] || cmux_bin="$(command -v cmux || true)" + [[ -n "$cmux_bin" ]] || return 1 + + # Keep stale/hung socket checks bounded so claude startup does not block + # behind the CLI default timeout (15s). + CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=0.75 \ + "$cmux_bin" --socket "$socket" ping >/dev/null 2>&1 +} + +# Pass through if not in a cmux terminal, hooks are disabled, or the cmux +# socket is unavailable (stale env / app not running). +IN_CMUX=0 +if [[ -n "$CMUX_SURFACE_ID" ]]; then + IN_CMUX=1 +fi + +if [[ "$IN_CMUX" == "0" || "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]] || ! cmux_socket_available; then + # In cmux-launched shells, preserve old behavior and always clear nested + # Claude session markers, even when we must pass through due to stale socket. + if [[ "$IN_CMUX" == "1" ]]; then + unset CLAUDECODE + fi REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; } exec "$REAL_CLAUDE" "$@" fi diff --git a/Resources/bin/open b/Resources/bin/open new file mode 100755 index 00000000..203ba1db --- /dev/null +++ b/Resources/bin/open @@ -0,0 +1,466 @@ +#!/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 +} + +is_file_url() { + local value="$1" + case "$value" in + [Ff][Ii][Ll][Ee]://*) + return 0 + ;; + esac + return 1 +} + +has_uri_scheme() { + local value="$1" + [[ "$value" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]] +} + +is_html_extension() { + local value + value="$(to_lower_ascii "$(trim "$1")")" + case "$value" in + *.html|*.htm) + return 0 + ;; + esac + return 1 +} + +is_explicit_local_path() { + local value="$1" + case "$value" in + /*|./*|../*|~|~/*) + return 0 + ;; + esac + return 1 +} + +file_url_points_to_html() { + local value="$1" + if [[ -n "$PYTHON3_BIN" ]]; then + "$PYTHON3_BIN" - "$value" <<'PY' >/dev/null 2>&1 +import sys +from urllib.parse import unquote, urlsplit + +value = sys.argv[1].strip() +if not value: + raise SystemExit(1) + +parts = urlsplit(value) +path = unquote(parts.path or "") +lower = path.lower() +if lower.endswith(".html") or lower.endswith(".htm"): + raise SystemExit(0) +raise SystemExit(1) +PY + return $? + fi + + local without_fragment="${value%%\#*}" + local without_query="${without_fragment%%\?*}" + local remainder path_part + + case "$without_query" in + [Ff][Ii][Ll][Ee]://*) + remainder="${without_query#*://}" + ;; + *) + return 1 + ;; + esac + + if [[ "$remainder" == /* ]]; then + path_part="$remainder" + elif [[ "$remainder" == */* ]]; then + path_part="/${remainder#*/}" + else + return 1 + fi + + is_html_extension "$path_part" +} + +path_to_file_url_without_python() { + local raw="$1" + local expanded="$raw" + case "$expanded" in + "~") + expanded="$HOME" + ;; + "~/"*) + expanded="$HOME/${expanded#~/}" + ;; + esac + + local absolute + if [[ "$expanded" == /* ]]; then + absolute="$expanded" + else + absolute="$(pwd)/$expanded" + fi + + local directory="$absolute" + local basename="" + if [[ "$absolute" == */* ]]; then + directory="${absolute%/*}" + basename="${absolute##*/}" + fi + + local resolved_directory + if resolved_directory="$(cd "$directory" 2>/dev/null && pwd -P)"; then + absolute="$resolved_directory" + if [[ -n "$basename" ]]; then + absolute="$absolute/$basename" + fi + fi + + local encoded="" + local length=${#absolute} + local index char hex + local LC_ALL=C + for ((index = 0; index < length; index++)); do + char="${absolute:index:1}" + case "$char" in + [a-zA-Z0-9.~_-]|/) + encoded+="$char" + ;; + *) + printf -v hex '%02X' "'$char" + encoded+="%$hex" + ;; + esac + done + printf 'file://%s\n' "$encoded" +} + +path_to_file_url() { + local raw="$1" + if [[ -n "$PYTHON3_BIN" ]]; then + local converted + if converted="$("$PYTHON3_BIN" - "$raw" <<'PY' 2>/dev/null +import pathlib +import sys + +raw = sys.argv[1] +if not raw: + raise SystemExit(1) + +path = pathlib.Path(raw).expanduser() +if path.is_absolute(): + resolved = path.resolve(strict=False) +else: + resolved = (pathlib.Path.cwd() / path).resolve(strict=False) + +sys.stdout.write(resolved.as_uri()) +PY + )"; then + printf '%s\n' "$converted" + return 0 + fi + fi + + path_to_file_url_without_python "$raw" +} + +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 and route eligible browser targets to cmux. +passthrough=false +cmux_targets=() +passthrough_args=() +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 + cmux_targets+=("$arg") + elif is_file_url "$arg"; then + if file_url_points_to_html "$arg"; then + cmux_targets+=("$arg") + else + passthrough_args+=("$arg") + fi + elif has_uri_scheme "$arg"; then + passthrough_args+=("$arg") + elif is_html_extension "$arg"; then + if is_explicit_local_path "$arg" || [[ -e "$arg" ]]; then + if local_file_url="$(path_to_file_url "$arg")"; then + cmux_targets+=("$local_file_url") + else + passthrough_args+=("$arg") + fi + else + passthrough_args+=("$arg") + fi + else + passthrough_args+=("$arg") + fi + ;; + esac +done + +if [[ "$passthrough" == true ]] || [[ ${#cmux_targets[@]} -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. +# External-open pattern rules are evaluated in-app (NSRegularExpression) so +# terminal link clicks and intercepted `open` commands share one regex dialect. +failed_urls=() +for url in "${cmux_targets[@]}"; do + if is_http_url "$url" && ! host_matches_whitelist "$url"; then + failed_urls+=("$url") + continue + fi + CMUX_RESPECT_EXTERNAL_OPEN_RULES=1 "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") +done + +# Fall back to system open for unmatched args and URLs that failed. +if [[ ${#passthrough_args[@]} -gt 0 ]] || [[ ${#failed_urls[@]} -gt 0 ]]; then + system_open "${passthrough_args[@]}" "${failed_urls[@]}" +fi diff --git a/Resources/cmux.sdef b/Resources/cmux.sdef new file mode 100644 index 00000000..b55edd4b --- /dev/null +++ b/Resources/cmux.sdef @@ -0,0 +1,192 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd"> + +<dictionary title="cmux Scripting Dictionary"> + <suite name="cmux Suite" code="Cmux" description="cmux scripting support."> + <class name="application" code="capp" description="The cmux application."> + <cocoa class="NSApplication"/> + <property name="name" code="pnam" type="text" access="r" description="The name of the application."/> + <property name="frontmost" code="pisf" type="boolean" access="r" description="Is this the active application?"> + <cocoa key="isActive"/> + </property> + <property name="front window" code="CMFW" type="window" access="r" description="The frontmost cmux window."> + <cocoa key="frontWindow"/> + </property> + <property name="version" code="vers" type="text" access="r" description="The version number of the application."/> + <responds-to command="perform action"> + <cocoa method="handlePerformActionScriptCommand:"/> + </responds-to> + <responds-to command="new window"> + <cocoa method="handleNewWindowScriptCommand:"/> + </responds-to> + <responds-to command="new tab"> + <cocoa method="handleNewTabScriptCommand:"/> + </responds-to> + <responds-to command="quit"> + <cocoa method="handleQuitScriptCommand:"/> + </responds-to> + + <element type="window" access="r"> + <cocoa key="scriptWindows"/> + </element> + + <element type="terminal" access="r"> + <cocoa key="terminals"/> + </element> + </class> + + <class name="window" code="CMwn" plural="windows" description="A cmux window containing one or more workspaces."> + <cocoa class="CmuxScriptWindow"/> + <property name="id" code="ID " type="text" access="r" description="Stable ID for this window."/> + <property name="name" code="pnam" type="text" access="r" description="The title of the window."> + <cocoa key="title"/> + </property> + <property name="selected tab" code="CMsT" type="tab" access="r" description="The selected workspace in this window."> + <cocoa key="selectedTab"/> + </property> + <responds-to command="activate window"> + <cocoa method="handleActivateWindowCommand:"/> + </responds-to> + <responds-to command="close window"> + <cocoa method="handleCloseWindowCommand:"/> + </responds-to> + <element type="tab" access="r"> + <cocoa key="tabs"/> + </element> + <element type="terminal" access="r"> + <cocoa key="terminals"/> + </element> + </class> + + <class name="tab" code="CMtb" plural="tabs" description="A cmux workspace."> + <cocoa class="CmuxScriptTab"/> + <property name="id" code="ID " type="text" access="r" description="Stable ID for this workspace."/> + <property name="name" code="pnam" type="text" access="r" description="The title of the workspace."> + <cocoa key="title"/> + </property> + <property name="index" code="pidx" type="integer" access="r" description="1-based index of this workspace in its window."/> + <property name="selected" code="CMsl" type="boolean" access="r" description="Whether this workspace is selected in its window."/> + <property name="focused terminal" code="CMfT" type="terminal" access="r" description="The currently focused terminal panel in this workspace."> + <cocoa key="focusedTerminal"/> + </property> + <responds-to command="select tab"> + <cocoa method="handleSelectTabCommand:"/> + </responds-to> + <responds-to command="close tab"> + <cocoa method="handleCloseTabCommand:"/> + </responds-to> + <element type="terminal" access="r"> + <cocoa key="terminals"/> + </element> + </class> + + <class name="terminal" code="CMtr" plural="terminals" description="An individual terminal panel."> + <cocoa class="CmuxScriptTerminal"/> + <property name="id" code="ID " type="text" access="r" description="Stable ID for this terminal panel."/> + <property name="name" code="pnam" type="text" access="r" description="Current terminal title."> + <cocoa key="title"/> + </property> + <property name="working directory" code="CMwd" type="text" access="r" description="Current working directory for the terminal process."> + <cocoa key="workingDirectory"/> + </property> + <responds-to command="split"> + <cocoa method="handleSplitCommand:"/> + </responds-to> + <responds-to command="focus"> + <cocoa method="handleFocusCommand:"/> + </responds-to> + <responds-to command="close"> + <cocoa method="handleCloseCommand:"/> + </responds-to> + </class> + + <enumeration name="split direction" code="CMSD" description="Direction for a new split."> + <enumerator name="right" code="GSrt" description="Split to the right."/> + <enumerator name="left" code="GSlf" description="Split to the left."/> + <enumerator name="down" code="GSdn" description="Split downward."/> + <enumerator name="up" code="GSup" description="Split upward."/> + </enumeration> + + <command name="perform action" code="CmuxPfAc" description="Perform a Ghostty action string on a terminal."> + <direct-parameter type="text" description="The Ghostty action string."/> + <parameter name="on" code="CMoT" type="terminal" description="Target terminal."> + <cocoa key="on"/> + </parameter> + <result type="boolean" description="True when the action was performed."/> + </command> + + <command name="new window" code="CmuxNWin" description="Create a new cmux window."> + <result type="window" description="The newly created window."/> + </command> + + <command name="new tab" code="CmuxNTab" description="Create a new workspace."> + <parameter name="in" code="CMtW" type="window" optional="yes" description="Target window for the new workspace."> + <cocoa key="window"/> + </parameter> + <result type="tab" description="The newly created workspace."/> + </command> + + <command name="split" code="CmuxSplt" description="Split a terminal in the given direction."> + <direct-parameter type="specifier" description="The terminal to split."/> + <parameter name="direction" code="CMpd" type="split direction" description="The direction to split."> + <cocoa key="direction"/> + </parameter> + <result type="terminal" description="The newly created terminal."/> + </command> + + <command name="focus" code="CmuxFcus" description="Focus a terminal, bringing its window to the front."> + <direct-parameter type="specifier" description="The terminal to focus."/> + </command> + + <command name="close" code="CmuxClos" description="Close a terminal."> + <direct-parameter type="specifier" description="The terminal to close."/> + </command> + + <command name="activate window" code="CmuxAcWn" description="Activate a cmux window, bringing it to the front."> + <direct-parameter type="specifier" description="The window to activate."/> + </command> + + <command name="select tab" code="CmuxSlTb" description="Select a workspace in its window."> + <direct-parameter type="specifier" description="The workspace to select."/> + </command> + + <command name="close tab" code="CmuxClTb" description="Close a workspace."> + <direct-parameter type="specifier" description="The workspace to close."/> + </command> + + <command name="close window" code="CmuxClWn" description="Close a window."> + <direct-parameter type="specifier" description="The window to close."/> + </command> + + <command name="input text" code="CmuxInTx" description="Input text to a terminal as if it was pasted."> + <cocoa class="CmuxScriptInputTextCommand"/> + <direct-parameter type="text" description="The text to input."/> + <parameter name="to" code="CMiT" type="terminal" description="The terminal to input text to."> + <cocoa key="terminal"/> + </parameter> + </command> + </suite> + + <suite name="Standard Suite" code="????" description="Common classes and commands for all applications."> + <command name="count" code="corecnte" description="Return the number of elements of a particular class within an object."> + <cocoa class="NSCountCommand"/> + <access-group identifier="*"/> + <direct-parameter type="specifier" requires-access="r" description="The objects to be counted."/> + <parameter name="each" code="kocl" type="type" optional="yes" description="The class of objects to be counted." hidden="yes"> + <cocoa key="ObjectClass"/> + </parameter> + <result type="integer" description="The count."/> + </command> + + <command name="exists" code="coredoex" description="Verify that an object exists."> + <cocoa class="NSExistsCommand"/> + <access-group identifier="*"/> + <direct-parameter type="any" requires-access="r" description="The object(s) to check."/> + <result type="boolean" description="Did the object(s) exist?"/> + </command> + + <command name="quit" code="aevtquit" description="Quit the application."> + <cocoa class="NSQuitCommand"/> + </command> + </suite> +</dictionary> diff --git a/Resources/shell-integration/.zshenv b/Resources/shell-integration/.zshenv index 21570241..74241671 100644 --- a/Resources/shell-integration/.zshenv +++ b/Resources/shell-integration/.zshenv @@ -31,7 +31,10 @@ fi if [[ -o interactive ]]; then # We overwrote GhosttyKit's injected ZDOTDIR, so manually load Ghostty's # zsh integration if available. - if [[ -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then + # + # We can't rely on GHOSTTY_ZSH_ZDOTDIR here because Ghostty's own zsh + # bootstrap unsets it before chaining into this cmux wrapper. + if [[ "${CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then builtin typeset _cmux_ghostty="$GHOSTTY_RESOURCES_DIR/shell-integration/zsh/ghostty-integration" [[ -r "$_cmux_ghostty" ]] && builtin source -- "$_cmux_ghostty" fi diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 4f8c832f..ab4b6e2c 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -3,9 +3,9 @@ _cmux_send() { local payload="$1" if command -v ncat >/dev/null 2>&1; then - printf '%s\n' "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only + printf '%s\n' "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only elif command -v socat >/dev/null 2>&1; then - printf '%s\n' "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH" + printf '%s\n' "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH" elif command -v nc >/dev/null 2>&1; then # Some nc builds don't support unix sockets, but keep as a last-ditch fallback. # @@ -23,16 +23,72 @@ _cmux_send() { fi } +_cmux_restore_scrollback_once() { + local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" + [[ -n "$path" ]] || return 0 + unset CMUX_RESTORE_SCROLLBACK_FILE + + if [[ -r "$path" ]]; then + /bin/cat -- "$path" 2>/dev/null || true + /bin/rm -f -- "$path" >/dev/null 2>&1 || true + fi +} +_cmux_restore_scrollback_once + # Throttle heavy work to avoid prompt latency. _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}" _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" _CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}" _CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}" +_CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}" +_CMUX_GIT_HEAD_LAST_PWD="${_CMUX_GIT_HEAD_LAST_PWD:-}" +_CMUX_GIT_HEAD_PATH="${_CMUX_GIT_HEAD_PATH:-}" +_CMUX_GIT_HEAD_SIGNATURE="${_CMUX_GIT_HEAD_SIGNATURE:-}" +_CMUX_PR_POLL_PID="${_CMUX_PR_POLL_PID:-}" +_CMUX_PR_POLL_PWD="${_CMUX_PR_POLL_PWD:-}" +_CMUX_PR_POLL_INTERVAL="${_CMUX_PR_POLL_INTERVAL:-45}" +_CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}" +_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" +_cmux_git_resolve_head_path() { + # Resolve the HEAD file path without invoking git (fast; works for worktrees). + local dir="$PWD" + while :; do + if [[ -d "$dir/.git" ]]; then + printf '%s\n' "$dir/.git/HEAD" + return 0 + fi + if [[ -f "$dir/.git" ]]; then + local line gitdir + IFS= read -r line < "$dir/.git" || line="" + if [[ "$line" == gitdir:* ]]; then + gitdir="${line#gitdir:}" + gitdir="${gitdir## }" + gitdir="${gitdir%% }" + [[ -n "$gitdir" ]] || return 1 + [[ "$gitdir" != /* ]] && gitdir="$dir/$gitdir" + printf '%s\n' "$gitdir/HEAD" + return 0 + fi + fi + [[ "$dir" == "/" || -z "$dir" ]] && break + dir="$(dirname "$dir")" + done + return 1 +} + +_cmux_git_head_signature() { + local head_path="$1" + [[ -n "$head_path" && -r "$head_path" ]] || return 1 + local line + IFS= read -r line < "$head_path" || return 1 + printf '%s\n' "$line" +} + _cmux_report_tty_once() { # Send the TTY name to the app once per session so the batched port scanner # knows which TTY belongs to this panel. @@ -44,7 +100,7 @@ _cmux_report_tty_once() { _CMUX_TTY_REPORTED=1 { _cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - } >/dev/null 2>&1 & + } >/dev/null 2>&1 & disown } _cmux_ports_kick() { @@ -56,7 +112,183 @@ _cmux_ports_kick() { _CMUX_PORTS_LAST_RUN=$SECONDS { _cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + } >/dev/null 2>&1 & disown +} + +_cmux_clear_pr_for_panel() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +} + +_cmux_pr_output_indicates_no_pull_request() { + local output="$1" + output="$(printf '%s' "$output" | tr '[:upper:]' '[:lower:]')" + [[ "$output" == *"no pull requests found"* \ + || "$output" == *"no pull request found"* \ + || "$output" == *"no pull requests associated"* \ + || "$output" == *"no pull request associated"* ]] +} + +_cmux_report_pr_for_path() { + local repo_path="$1" + [[ -n "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } + [[ -d "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local branch gh_output gh_error="" err_file="" gh_status number state url status_opt="" + branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" + if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then + _cmux_clear_pr_for_panel + return 0 + fi + + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + gh_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + gh_status=$? + if [[ -f "$err_file" ]]; then + gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + if (( gh_status != 0 )); then + if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + _cmux_clear_pr_for_panel + return 0 + fi + # Preserve the last-known PR badge when gh fails transiently, then retry + # on the next background poll instead of clearing visible state. + return 1 + fi + if [[ -z "$gh_output" ]]; then + _cmux_clear_pr_for_panel + return 0 + fi + + IFS=$'\t' read -r number state url <<< "$gh_output" + if [[ -z "$number" || -z "$url" ]]; then + return 1 + fi + + case "$state" in + MERGED) status_opt="--state=merged" ;; + OPEN) status_opt="--state=open" ;; + CLOSED) status_opt="--state=closed" ;; + *) return 1 ;; + esac + + _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +} + +_cmux_child_pids() { + local parent_pid="$1" + [[ -n "$parent_pid" ]] || return 0 + /bin/ps -ax -o pid= -o ppid= 2>/dev/null | /usr/bin/awk -v parent="$parent_pid" '$2 == parent { print $1 }' +} + +_cmux_kill_process_tree() { + local pid="$1" + local signal="${2:-TERM}" + local child_pid="" + [[ -n "$pid" ]] || return 0 + + while IFS= read -r child_pid; do + [[ -n "$child_pid" ]] || continue + [[ "$child_pid" == "$pid" ]] && continue + _cmux_kill_process_tree "$child_pid" "$signal" + done < <(_cmux_child_pids "$pid") + + kill "-$signal" "$pid" >/dev/null 2>&1 || true +} + +_cmux_run_pr_probe_with_timeout() { + local repo_path="$1" + local probe_pid="" + local started_at=$SECONDS + local now=$started_at + + ( + _cmux_report_pr_for_path "$repo_path" + ) & + probe_pid=$! + + while kill -0 "$probe_pid" >/dev/null 2>&1; do + sleep 1 + now=$SECONDS + if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _cmux_kill_process_tree "$probe_pid" TERM + sleep 0.2 + if kill -0 "$probe_pid" >/dev/null 2>&1; then + _cmux_kill_process_tree "$probe_pid" KILL + sleep 0.2 + fi + if ! kill -0 "$probe_pid" >/dev/null 2>&1; then + wait "$probe_pid" >/dev/null 2>&1 || true + fi + return 1 + fi + done + + wait "$probe_pid" +} + +_cmux_stop_pr_poll_loop() { + if [[ -n "$_CMUX_PR_POLL_PID" ]]; then + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM + sleep 0.1 + if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL + fi + _CMUX_PR_POLL_PID="" + fi +} + +_cmux_start_pr_poll_loop() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local watch_pwd="${1:-$PWD}" + local force_restart="${2:-0}" + local watch_shell_pid="$$" + local interval="${_CMUX_PR_POLL_INTERVAL:-45}" + + if [[ "$force_restart" != "1" && "$watch_pwd" == "$_CMUX_PR_POLL_PWD" && -n "$_CMUX_PR_POLL_PID" ]] \ + && kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then + return 0 + fi + + _cmux_stop_pr_poll_loop + _CMUX_PR_POLL_PWD="$watch_pwd" + + { + while :; do + kill -0 "$watch_shell_pid" 2>/dev/null || break + _cmux_run_pr_probe_with_timeout "$watch_pwd" || true + sleep "$interval" + done } >/dev/null 2>&1 & + _CMUX_PR_POLL_PID=$! + disown "$_CMUX_PR_POLL_PID" 2>/dev/null || disown +} + +_cmux_bash_cleanup() { + _cmux_stop_pr_poll_loop } _cmux_prompt_command() { @@ -67,6 +299,18 @@ _cmux_prompt_command() { local now=$SECONDS local pwd="$PWD" + # Post-wake socket writes can occasionally leave a probe process wedged. + # If one probe is stale, clear the guard so fresh async probes can resume. + if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then + if ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then + _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 + elif (( _CMUX_GIT_JOB_STARTED_AT > 0 )) && (( now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 + fi + fi + # Resolve TTY name once. if [[ -z "$_CMUX_TTY_NAME" ]]; then local t @@ -83,7 +327,26 @@ _cmux_prompt_command() { { local qpwd="${pwd//\"/\\\"}" _cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - } >/dev/null 2>&1 & + } >/dev/null 2>&1 & disown + fi + + # Branch can change via aliases/tools while an older probe is still in flight. + # Track .git/HEAD content so we can restart stale probes immediately. + local git_head_changed=0 + if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then + _CMUX_GIT_HEAD_LAST_PWD="$pwd" + _CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)" + _CMUX_GIT_HEAD_SIGNATURE="" + fi + if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then + local head_signature + head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" + if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # Also invalidate the PR poller so it refreshes with the new branch. + _CMUX_PR_FORCE=1 + fi fi # Git branch/dirty can change without a directory change (e.g. `git checkout`), @@ -91,9 +354,10 @@ _cmux_prompt_command() { # When pwd changes (cd into a different repo), kill the old probe and start fresh # so the sidebar picks up the new branch immediately. if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then - if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then + if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" || "$git_head_changed" == "1" ]]; then kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 fi fi @@ -113,6 +377,33 @@ _cmux_prompt_command() { fi } >/dev/null 2>&1 & _CMUX_GIT_JOB_PID=$! + disown + _CMUX_GIT_JOB_STARTED_AT=$now + fi + + # Pull request metadata is remote state. Keep polling while the shell sits + # at a prompt so newly created or merged PRs appear without another command. + local should_restart_pr_poll=0 + local pr_context_changed=0 + if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then + pr_context_changed=1 + elif [[ "$git_head_changed" == "1" ]]; then + pr_context_changed=1 + fi + if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" || "$git_head_changed" == "1" ]]; then + should_restart_pr_poll=1 + elif (( _CMUX_PR_FORCE )); then + should_restart_pr_poll=1 + elif [[ -z "$_CMUX_PR_POLL_PID" ]] || ! kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then + should_restart_pr_poll=1 + fi + + if (( should_restart_pr_poll )); then + _CMUX_PR_FORCE=0 + if (( pr_context_changed )); then + _cmux_clear_pr_for_panel + fi + _cmux_start_pr_poll_loop "$pwd" 1 fi # Ports: lightweight kick to the app's batched scanner every ~10s. @@ -150,15 +441,17 @@ _cmux_install_prompt_command() { fi } -# Ensure Resources/bin is at the front of PATH. Shell init (.bashrc/.bash_profile) -# may prepend other dirs that push our wrapper behind the system claude binary. +# Ensure Resources/bin is at the front of PATH, and remove the app's +# Contents/MacOS entry so the GUI cmux binary cannot shadow the CLI cmux. +# Shell init (.bashrc/.bash_profile) may prepend other dirs after launch. _cmux_fix_path() { if [[ -n "${GHOSTTY_BIN_DIR:-}" ]]; then - local bin_dir="${GHOSTTY_BIN_DIR%/MacOS}" - bin_dir="${bin_dir}/Resources/bin" + local gui_dir="${GHOSTTY_BIN_DIR%/}" + local bin_dir="${gui_dir%/MacOS}/Resources/bin" if [[ -d "$bin_dir" ]]; then local new_path=":${PATH}:" new_path="${new_path//:${bin_dir}:/:}" + new_path="${new_path//:${gui_dir}:/:}" new_path="${new_path#:}" new_path="${new_path%:}" PATH="${bin_dir}:${new_path}" diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 6c9575f0..821f3d19 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -4,9 +4,9 @@ _cmux_send() { local payload="$1" if command -v ncat >/dev/null 2>&1; then - print -r -- "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only + print -r -- "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only elif command -v socat >/dev/null 2>&1; then - print -r -- "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH" + print -r -- "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH" elif command -v nc >/dev/null 2>&1; then # Some nc builds don't support unix sockets, but keep as a last-ditch fallback. # @@ -24,35 +24,40 @@ _cmux_send() { fi } +_cmux_restore_scrollback_once() { + local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" + [[ -n "$path" ]] || return 0 + unset CMUX_RESTORE_SCROLLBACK_FILE + + if [[ -r "$path" ]]; then + /bin/cat -- "$path" 2>/dev/null || true + /bin/rm -f -- "$path" >/dev/null 2>&1 || true + fi +} +_cmux_restore_scrollback_once + # Throttle heavy work to avoid prompt latency. typeset -g _CMUX_PWD_LAST_PWD="" typeset -g _CMUX_GIT_LAST_PWD="" typeset -g _CMUX_GIT_LAST_RUN=0 typeset -g _CMUX_GIT_JOB_PID="" +typeset -g _CMUX_GIT_JOB_STARTED_AT=0 typeset -g _CMUX_GIT_FORCE=0 typeset -g _CMUX_GIT_HEAD_LAST_PWD="" typeset -g _CMUX_GIT_HEAD_PATH="" -typeset -g _CMUX_GIT_HEAD_MTIME=0 -typeset -g _CMUX_HAVE_ZSTAT=0 +typeset -g _CMUX_GIT_HEAD_SIGNATURE="" +typeset -g _CMUX_GIT_HEAD_WATCH_PID="" +typeset -g _CMUX_PR_POLL_PID="" +typeset -g _CMUX_PR_POLL_PWD="" +typeset -g _CMUX_PR_POLL_INTERVAL=45 +typeset -g _CMUX_PR_FORCE=0 +typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20 typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 -_cmux_ensure_zstat() { - # zstat is substantially cheaper than spawning external `stat`. - if (( _CMUX_HAVE_ZSTAT != 0 )); then - return 0 - fi - if zmodload -F zsh/stat b:zstat 2>/dev/null; then - _CMUX_HAVE_ZSTAT=1 - return 0 - fi - _CMUX_HAVE_ZSTAT=-1 - return 1 -} - _cmux_git_resolve_head_path() { # Resolve the HEAD file path without invoking git (fast; works for worktrees). local dir="$PWD" @@ -80,27 +85,15 @@ _cmux_git_resolve_head_path() { return 1 } -_cmux_git_head_mtime() { +_cmux_git_head_signature() { local head_path="$1" - [[ -n "$head_path" && -f "$head_path" ]] || { print -r -- 0; return 0; } - - if _cmux_ensure_zstat; then - typeset -A st - if zstat -H st +mtime -- "$head_path" 2>/dev/null; then - print -r -- "${st[mtime]:-0}" - return 0 - fi - fi - - # Fallback for environments where zsh/stat isn't available. - if command -v stat >/dev/null 2>&1; then - local mtime - mtime="$(stat -f %m "$head_path" 2>/dev/null || stat -c %Y "$head_path" 2>/dev/null || echo 0)" - print -r -- "$mtime" + [[ -n "$head_path" && -r "$head_path" ]] || return 1 + local line="" + if IFS= read -r line < "$head_path"; then + print -r -- "$line" return 0 fi - - print -r -- 0 + return 1 } _cmux_report_tty_once() { @@ -129,6 +122,236 @@ _cmux_ports_kick() { } >/dev/null 2>&1 &! } +_cmux_report_git_branch_for_path() { + local repo_path="$1" + [[ -n "$repo_path" ]] || return 0 + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local branch dirty_opt="" first + branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" + if [[ -n "$branch" ]]; then + first="$(git -C "$repo_path" 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 --panel=$CMUX_PANEL_ID" + else + _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + fi +} + +_cmux_clear_pr_for_panel() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +} + +_cmux_pr_output_indicates_no_pull_request() { + local output="${1:l}" + [[ "$output" == *"no pull requests found"* \ + || "$output" == *"no pull request found"* \ + || "$output" == *"no pull requests associated"* \ + || "$output" == *"no pull request associated"* ]] +} + +_cmux_report_pr_for_path() { + local repo_path="$1" + [[ -n "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } + [[ -d "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status + branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" + if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then + _cmux_clear_pr_for_panel + return 0 + fi + + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + gh_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + gh_status=$? + if [[ -f "$err_file" ]]; then + gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + if (( gh_status != 0 )); then + if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + _cmux_clear_pr_for_panel + return 0 + fi + # Keep the last-known PR badge on transient gh failures (auth hiccups, + # API lag after creation, or rate limiting) and retry on the next poll. + return 1 + fi + if [[ -z "$gh_output" ]]; then + _cmux_clear_pr_for_panel + return 0 + fi + + local IFS=$'\t' + read -r number state url <<< "$gh_output" + if [[ -z "$number" ]] || [[ -z "$url" ]]; then + return 1 + fi + + case "$state" in + MERGED) status_opt="--state=merged" ;; + OPEN) status_opt="--state=open" ;; + CLOSED) status_opt="--state=closed" ;; + *) return 1 ;; + esac + + _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +} + +_cmux_child_pids() { + local parent_pid="$1" + [[ -n "$parent_pid" ]] || return 0 + /bin/ps -ax -o pid= -o ppid= 2>/dev/null | /usr/bin/awk -v parent="$parent_pid" '$2 == parent { print $1 }' +} + +_cmux_kill_process_tree() { + local pid="$1" + local signal="${2:-TERM}" + local child_pid="" + [[ -n "$pid" ]] || return 0 + + while IFS= read -r child_pid; do + [[ -n "$child_pid" ]] || continue + [[ "$child_pid" == "$pid" ]] && continue + _cmux_kill_process_tree "$child_pid" "$signal" + done < <(_cmux_child_pids "$pid") + + kill "-$signal" "$pid" >/dev/null 2>&1 || true +} + +_cmux_run_pr_probe_with_timeout() { + local repo_path="$1" + local probe_pid="" + local started_at=$EPOCHSECONDS + local now=$started_at + + ( + _cmux_report_pr_for_path "$repo_path" + ) & + probe_pid=$! + + while kill -0 "$probe_pid" >/dev/null 2>&1; do + sleep 1 + now=$EPOCHSECONDS + if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _cmux_kill_process_tree "$probe_pid" TERM + sleep 0.2 + if kill -0 "$probe_pid" >/dev/null 2>&1; then + _cmux_kill_process_tree "$probe_pid" KILL + sleep 0.2 + fi + if ! kill -0 "$probe_pid" >/dev/null 2>&1; then + wait "$probe_pid" >/dev/null 2>&1 || true + fi + return 1 + fi + done + + wait "$probe_pid" +} + +_cmux_stop_pr_poll_loop() { + if [[ -n "$_CMUX_PR_POLL_PID" ]]; then + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM + sleep 0.1 + if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL + fi + _CMUX_PR_POLL_PID="" + fi +} + +_cmux_start_pr_poll_loop() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local watch_pwd="${1:-$PWD}" + local force_restart="${2:-0}" + local watch_shell_pid="$$" + local interval="${_CMUX_PR_POLL_INTERVAL:-45}" + + if [[ "$force_restart" != "1" && "$watch_pwd" == "$_CMUX_PR_POLL_PWD" && -n "$_CMUX_PR_POLL_PID" ]] \ + && kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then + return 0 + fi + + _cmux_stop_pr_poll_loop + _CMUX_PR_POLL_PWD="$watch_pwd" + + { + while true; do + kill -0 "$watch_shell_pid" >/dev/null 2>&1 || break + _cmux_run_pr_probe_with_timeout "$watch_pwd" || true + sleep "$interval" + done + } >/dev/null 2>&1 &! + _CMUX_PR_POLL_PID=$! +} + +_cmux_stop_git_head_watch() { + if [[ -n "$_CMUX_GIT_HEAD_WATCH_PID" ]]; then + kill "$_CMUX_GIT_HEAD_WATCH_PID" >/dev/null 2>&1 || true + _CMUX_GIT_HEAD_WATCH_PID="" + fi +} + +_cmux_start_git_head_watch() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local watch_pwd="$PWD" + local watch_head_path + watch_head_path="$(_cmux_git_resolve_head_path 2>/dev/null || true)" + [[ -n "$watch_head_path" ]] || return 0 + + local watch_head_signature + watch_head_signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)" + + _CMUX_GIT_HEAD_LAST_PWD="$watch_pwd" + _CMUX_GIT_HEAD_PATH="$watch_head_path" + _CMUX_GIT_HEAD_SIGNATURE="$watch_head_signature" + + _cmux_stop_git_head_watch + { + local last_signature="$watch_head_signature" + while true; do + sleep 1 + + local signature + signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)" + if [[ -n "$signature" && "$signature" != "$last_signature" ]]; then + last_signature="$signature" + _cmux_report_git_branch_for_path "$watch_pwd" + fi + done + } >/dev/null 2>&1 &! + _CMUX_GIT_HEAD_WATCH_PID=$! +} + _cmux_preexec() { if [[ -z "$_CMUX_TTY_NAME" ]]; then local t @@ -143,15 +366,20 @@ _cmux_preexec() { local cmd="${1## }" case "$cmd" in git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *) - _CMUX_GIT_FORCE=1 ;; + _CMUX_GIT_FORCE=1 + _CMUX_PR_FORCE=1 ;; esac # Register TTY + kick batched port scan for foreground commands (servers). _cmux_report_tty_once _cmux_ports_kick + _cmux_stop_pr_poll_loop + _cmux_start_git_head_watch } _cmux_precmd() { + _cmux_stop_git_head_watch + # Skip if socket doesn't exist yet [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 @@ -171,6 +399,19 @@ _cmux_precmd() { local cmd_start="$_CMUX_CMD_START" _CMUX_CMD_START=0 + # Post-wake socket writes can occasionally leave a probe process wedged. + # If one probe is stale, clear the guard so fresh async probes can resume. + if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then + if ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then + _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 + elif (( _CMUX_GIT_JOB_STARTED_AT > 0 )) && (( now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 + _CMUX_GIT_FORCE=1 + fi + fi + # CWD: keep the app in sync with the actual shell directory. # This is also the simplest way to test sidebar directory behavior end-to-end. if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then @@ -183,23 +424,28 @@ _cmux_precmd() { fi # Git branch/dirty: update immediately on directory change, otherwise every ~3s. + # While a foreground command is running, _cmux_start_git_head_watch probes HEAD + # once per second so agent-initiated git checkouts still surface quickly. local should_git=0 + local git_head_changed=0 # Git branch can change without a `git ...`-prefixed command (aliases like `gco`, # tools like `gh pr checkout`, etc.). Detect HEAD changes and force a refresh. if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then _CMUX_GIT_HEAD_LAST_PWD="$pwd" _CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)" - _CMUX_GIT_HEAD_MTIME=0 + _CMUX_GIT_HEAD_SIGNATURE="" fi if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then - local head_mtime - head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)" - if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then - _CMUX_GIT_HEAD_MTIME="$head_mtime" + local head_signature + head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" + if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 # Treat HEAD file change like a git command — force-replace any # running probe so the sidebar picks up the new branch immediately. _CMUX_GIT_FORCE=1 + _CMUX_PR_FORCE=1 should_git=1 fi fi @@ -224,6 +470,7 @@ _cmux_precmd() { if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]] || (( _CMUX_GIT_FORCE )); then kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 else can_launch_git=0 fi @@ -234,21 +481,39 @@ _cmux_precmd() { _CMUX_GIT_LAST_PWD="$pwd" _CMUX_GIT_LAST_RUN=$now { - local branch dirty_opt="" - branch=$(git branch --show-current 2>/dev/null) - if [[ -n "$branch" ]]; then - 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 --panel=$CMUX_PANEL_ID" - else - _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - fi + _cmux_report_git_branch_for_path "$pwd" } >/dev/null 2>&1 &! _CMUX_GIT_JOB_PID=$! + _CMUX_GIT_JOB_STARTED_AT=$now fi fi + # Pull request metadata is remote state. Keep a lightweight background poll + # alive while the shell is idle so gh-created PRs and merge status changes + # appear even without another prompt. + local should_restart_pr_poll=0 + local pr_context_changed=0 + if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then + pr_context_changed=1 + elif (( git_head_changed )); then + pr_context_changed=1 + fi + if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then + should_restart_pr_poll=1 + elif (( _CMUX_PR_FORCE )); then + should_restart_pr_poll=1 + elif [[ -z "$_CMUX_PR_POLL_PID" ]] || ! kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then + should_restart_pr_poll=1 + fi + + if (( should_restart_pr_poll )); then + _CMUX_PR_FORCE=0 + if (( pr_context_changed )); then + _cmux_clear_pr_for_panel + fi + _cmux_start_pr_poll_loop "$pwd" 1 + fi + # Ports: lightweight kick to the app's batched scanner. # - Periodic scan to avoid stale values. # - Forced scan when a long-running command returns to the prompt (common when stopping a server). @@ -262,24 +527,32 @@ _cmux_precmd() { fi } -# Ensure Resources/bin is at the front of PATH. Shell init (.zprofile/.zshrc) -# may prepend other dirs that push our wrapper behind the system claude binary. +# Ensure Resources/bin is at the front of PATH, and remove the app's +# Contents/MacOS entry so the GUI cmux binary cannot shadow the CLI cmux. +# Shell init (.zprofile/.zshrc) may prepend other dirs after launch. # We fix this once on first prompt (after all init files have run). _cmux_fix_path() { if [[ -n "${GHOSTTY_BIN_DIR:-}" ]]; then - local bin_dir="${GHOSTTY_BIN_DIR%/MacOS}" - bin_dir="${bin_dir}/Resources/bin" + local gui_dir="${GHOSTTY_BIN_DIR%/}" + local bin_dir="${gui_dir%/MacOS}/Resources/bin" if [[ -d "$bin_dir" ]]; then - # Remove existing entry and re-prepend. + # Remove existing entries and re-prepend the CLI bin dir. local -a parts=("${(@s/:/)PATH}") parts=("${(@)parts:#$bin_dir}") + parts=("${(@)parts:#$gui_dir}") PATH="${bin_dir}:${(j/:/)parts}" fi fi add-zsh-hook -d precmd _cmux_fix_path } +_cmux_zshexit() { + _cmux_stop_git_head_watch + _cmux_stop_pr_poll_loop +} + autoload -Uz add-zsh-hook add-zsh-hook preexec _cmux_preexec add-zsh-hook precmd _cmux_precmd add-zsh-hook precmd _cmux_fix_path +add-zsh-hook zshexit _cmux_zshexit diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 23197bba..e441d37c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7,6 +7,352 @@ import Sentry import WebKit import Combine import ObjectiveC.runtime +import Darwin + +#if DEBUG +enum CmuxTypingTiming { + static let isEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["CMUX_TYPING_TIMING_LOGS"] == "1" || environment["CMUX_KEY_LATENCY_PROBE"] == "1" { + return true + } + let defaults = UserDefaults.standard + return defaults.bool(forKey: "cmuxTypingTimingLogs") || defaults.bool(forKey: "cmuxKeyLatencyProbe") + }() + static let isVerboseProbeEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["CMUX_KEY_LATENCY_PROBE"] == "1" { + return true + } + return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") + }() + private static let delayLogThresholdMs: Double = 6.0 + private static let durationLogThresholdMs: Double = 1.0 + + @inline(__always) + static func start() -> TimeInterval? { + guard isEnabled else { return nil } + return ProcessInfo.processInfo.systemUptime + } + + @inline(__always) + static func logEventDelay(path: String, event: NSEvent) { + guard isEnabled else { return } + guard event.timestamp > 0 else { return } + let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + guard shouldLog(delayMs: delayMs, elapsedMs: nil) else { return } + dlog("typing.delay path=\(path) delayMs=\(format(delayMs)) \(eventFields(event))") + } + + @inline(__always) + static func logDuration(path: String, startedAt: TimeInterval?, event: NSEvent? = nil, extra: String? = nil) { + CmuxMainThreadTurnProfiler.endMeasure(path, startedAt: startedAt) + guard let startedAt else { return } + let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) + let delayMs: Double? = { + guard let event, event.timestamp > 0 else { return nil } + return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + }() + guard shouldLog(delayMs: delayMs, elapsedMs: elapsedMs) else { return } + var line = "typing.timing path=\(path) elapsedMs=\(format(elapsedMs))" + if let event { + line += " \(eventFields(event))" + if let delayMs { + line += " delayMs=\(format(delayMs))" + } + } + if let extra, !extra.isEmpty { + line += " \(extra)" + } + dlog(line) + } + + @inline(__always) + static func logBreakdown( + path: String, + totalMs: Double, + event: NSEvent? = nil, + thresholdMs: Double = 2.0, + parts: [(String, Double)], + extra: String? = nil + ) { + guard isEnabled else { return } + let delayMs: Double? = { + guard let event, event.timestamp > 0 else { return nil } + return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + }() + let hasSlowPart = parts.contains { $0.1 >= thresholdMs } + guard isVerboseProbeEnabled || totalMs >= thresholdMs || hasSlowPart || (delayMs ?? 0) >= delayLogThresholdMs else { + return + } + var line = "typing.phase path=\(path) totalMs=\(format(totalMs))" + if let event { + line += " \(eventFields(event))" + } + if let delayMs { + line += " delayMs=\(format(delayMs))" + } + for (name, value) in parts where isVerboseProbeEnabled || value >= 0.05 { + line += " \(name)=\(format(value))" + } + if let extra, !extra.isEmpty { + line += " \(extra)" + } + dlog(line) + } + + @inline(__always) + private static func eventFields(_ event: NSEvent) -> String { + "eventType=\(event.type.rawValue) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)" + } + + @inline(__always) + private static func shouldLog(delayMs: Double?, elapsedMs: Double?) -> Bool { + if isVerboseProbeEnabled { + return true + } + if let delayMs, delayMs >= delayLogThresholdMs { + return true + } + if let elapsedMs, elapsedMs >= durationLogThresholdMs { + return true + } + return false + } + + @inline(__always) + private static func format(_ value: Double) -> String { + String(format: "%.2f", value) + } +} + +final class CmuxMainRunLoopStallMonitor { + static let shared = CmuxMainRunLoopStallMonitor() + + private let thresholdMs: Double = 8.0 + private var observer: CFRunLoopObserver? + private var installed = false + private var lastActivity: CFRunLoopActivity? + private var lastTimestamp: TimeInterval? + + private init() {} + + func installIfNeeded() { + guard CmuxTypingTiming.isEnabled else { return } + guard !installed else { return } + + var context = CFRunLoopObserverContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + observer = CFRunLoopObserverCreate( + kCFAllocatorDefault, + CFRunLoopActivity.allActivities.rawValue, + true, + CFIndex.max, + { _, activity, info in + guard let info else { return } + let monitor = Unmanaged<CmuxMainRunLoopStallMonitor>.fromOpaque(info).takeUnretainedValue() + monitor.handle(activity: activity) + }, + &context + ) + + guard let observer else { return } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + installed = true + } + + private func handle(activity: CFRunLoopActivity) { + let now = ProcessInfo.processInfo.systemUptime + defer { + lastActivity = activity + lastTimestamp = now + } + + guard let lastActivity, let lastTimestamp else { return } + let elapsedMs = max(0, (now - lastTimestamp) * 1000.0) + guard elapsedMs >= thresholdMs else { return } + if lastActivity == .beforeWaiting && activity == .afterWaiting { + return + } + + let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" + let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let currentEvent = NSApp.currentEvent.map { + "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" + } ?? "event=nil" + dlog( + "runloop.stall gapMs=\(String(format: "%.2f", elapsedMs)) prev=\(label(for: lastActivity)) " + + "next=\(label(for: activity)) mode=\(mode) firstResponder=\(firstResponder) \(currentEvent)" + ) + } + + private func label(for activity: CFRunLoopActivity) -> String { + switch activity { + case .entry: + return "entry" + case .beforeTimers: + return "beforeTimers" + case .beforeSources: + return "beforeSources" + case .beforeWaiting: + return "beforeWaiting" + case .afterWaiting: + return "afterWaiting" + case .exit: + return "exit" + default: + return "unknown(\(activity.rawValue))" + } + } +} + +final class CmuxMainThreadTurnProfiler { + static let shared = CmuxMainThreadTurnProfiler() + + private struct BucketStats { + var count: Int = 0 + var totalMs: Double = 0 + var maxMs: Double = 0 + } + + private let trackedThresholdMs: Double = 3.0 + private let countThreshold: Int = 16 + private var observer: CFRunLoopObserver? + private var installed = false + private var turnStart: TimeInterval? + private var buckets: [String: BucketStats] = [:] + + private init() {} + + @inline(__always) + static func endMeasure(_ bucket: String, startedAt: TimeInterval?) { + guard let startedAt, CmuxTypingTiming.isEnabled, Thread.isMainThread else { return } + let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) + shared.record(bucket: bucket, elapsedMs: elapsedMs, count: 1) + } + + func installIfNeeded() { + guard CmuxTypingTiming.isEnabled else { return } + guard !installed else { return } + + var context = CFRunLoopObserverContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + observer = CFRunLoopObserverCreate( + kCFAllocatorDefault, + CFRunLoopActivity.allActivities.rawValue, + true, + CFIndex.max, + { _, activity, info in + guard let info else { return } + let profiler = Unmanaged<CmuxMainThreadTurnProfiler>.fromOpaque(info).takeUnretainedValue() + profiler.handle(activity: activity) + }, + &context + ) + + guard let observer else { return } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + installed = true + } + + private func handle(activity: CFRunLoopActivity) { + let now = ProcessInfo.processInfo.systemUptime + switch activity { + case .entry, .afterWaiting: + turnStart = now + buckets.removeAll(keepingCapacity: true) + case .beforeWaiting, .exit: + flushTurn(at: now, nextActivity: activity) + default: + break + } + } + + private func record(bucket: String, elapsedMs: Double, count: Int) { + if turnStart == nil { + turnStart = ProcessInfo.processInfo.systemUptime + } + var stats = buckets[bucket, default: BucketStats()] + stats.count += count + stats.totalMs += elapsedMs + stats.maxMs = max(stats.maxMs, elapsedMs) + buckets[bucket] = stats + } + + private func flushTurn(at now: TimeInterval, nextActivity: CFRunLoopActivity) { + defer { + turnStart = nil + buckets.removeAll(keepingCapacity: true) + } + + guard let turnStart else { return } + guard !buckets.isEmpty else { return } + + let turnMs = max(0, (now - turnStart) * 1000.0) + let trackedMs = buckets.values.reduce(0) { $0 + $1.totalMs } + let totalCount = buckets.values.reduce(0) { $0 + $1.count } + guard trackedMs >= trackedThresholdMs || totalCount >= countThreshold else { return } + + let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" + let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let eventSummary = NSApp.currentEvent.map { + "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" + } ?? "event=nil" + let bucketSummary = buckets + .sorted { + if abs($0.value.totalMs - $1.value.totalMs) > 0.01 { + return $0.value.totalMs > $1.value.totalMs + } + return $0.value.count > $1.value.count + } + .prefix(8) + .map { key, value in + if value.totalMs > 0.05 || value.maxMs > 0.05 { + return "\(key)=\(value.count)/\(String(format: "%.2f", value.totalMs))/\(String(format: "%.2f", value.maxMs))" + } + return "\(key)=\(value.count)" + } + .joined(separator: " ") + + dlog( + "main.turn.work turnMs=\(String(format: "%.2f", turnMs)) trackedMs=\(String(format: "%.2f", trackedMs)) totalCount=\(totalCount) " + + "next=\(label(for: nextActivity)) mode=\(mode) firstResponder=\(firstResponder) \(eventSummary) " + + "\(bucketSummary)" + ) + } + + private func label(for activity: CFRunLoopActivity) -> String { + switch activity { + case .entry: + return "entry" + case .beforeTimers: + return "beforeTimers" + case .beforeSources: + return "beforeSources" + case .beforeWaiting: + return "beforeWaiting" + case .afterWaiting: + return "afterWaiting" + case .exit: + return "exit" + default: + return "unknown(\(activity.rawValue))" + } + } +} +#endif enum FinderServicePathResolver { private static func canonicalDirectoryPath(_ path: String) -> String { @@ -36,6 +382,662 @@ enum FinderServicePathResolver { } } +enum TerminalDirectoryOpenTarget: String, CaseIterable { + case androidStudio + case antigravity + case cursor + case finder + case ghostty + case iterm2 + case terminal + case tower + case vscode + case warp + case windsurf + case xcode + case zed + + struct DetectionEnvironment { + let homeDirectoryPath: String + let fileExistsAtPath: (String) -> Bool + let isExecutableFileAtPath: (String) -> Bool + + static let live = DetectionEnvironment( + homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, + fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, + isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) } + ) + } + + static var commandPaletteShortcutTargets: [Self] { + Array(allCases) + } + + static func availableTargets(in environment: DetectionEnvironment = .live) -> Set<Self> { + Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) + } + + static let cachedLiveAvailableTargets: Set<Self> = availableTargets(in: .live) + + var commandPaletteCommandId: String { + "palette.terminalOpenDirectory.\(rawValue)" + } + + var commandPaletteTitle: String { + switch self { + case .androidStudio: + return String(localized: "menu.openInAndroidStudio", defaultValue: "Open Current Directory in Android Studio") + case .antigravity: + return String(localized: "menu.openInAntigravity", defaultValue: "Open Current Directory in Antigravity") + case .cursor: + return String(localized: "menu.openInCursor", defaultValue: "Open Current Directory in Cursor") + case .finder: + return String(localized: "menu.openInFinder", defaultValue: "Open Current Directory in Finder") + case .ghostty: + return String(localized: "menu.openInGhostty", defaultValue: "Open Current Directory in Ghostty") + case .iterm2: + return String(localized: "menu.openInITerm2", defaultValue: "Open Current Directory in iTerm2") + case .terminal: + return String(localized: "menu.openInTerminal", defaultValue: "Open Current Directory in Terminal") + case .tower: + return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") + case .vscode: + return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") + case .warp: + return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") + case .windsurf: + return String(localized: "menu.openInWindsurf", defaultValue: "Open Current Directory in Windsurf") + case .xcode: + return String(localized: "menu.openInXcode", defaultValue: "Open Current Directory in Xcode") + case .zed: + return String(localized: "menu.openInZed", defaultValue: "Open Current Directory in Zed") + } + } + + var commandPaletteKeywords: [String] { + let common = ["terminal", "directory", "open", "ide"] + switch self { + case .androidStudio: + return common + ["android", "studio"] + case .antigravity: + return common + ["antigravity"] + case .cursor: + return common + ["cursor"] + case .finder: + return common + ["finder", "file", "manager", "reveal"] + case .ghostty: + return common + ["ghostty", "terminal", "shell"] + case .iterm2: + return common + ["iterm", "iterm2", "terminal", "shell"] + case .terminal: + return common + ["terminal", "shell"] + case .tower: + return common + ["tower", "git", "client"] + case .vscode: + return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] + case .warp: + return common + ["warp", "terminal", "shell"] + case .windsurf: + return common + ["windsurf"] + case .xcode: + return common + ["xcode", "apple"] + case .zed: + return common + ["zed"] + } + } + + func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { + guard let applicationPath = applicationPath(in: environment) else { return false } + guard self == .vscode else { return true } + return VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true), + isExecutableAtPath: environment.isExecutableFileAtPath + ) != nil + } + + func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { + guard let path = applicationPath(in: environment) else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + } + + private func applicationPath(in environment: DetectionEnvironment) -> String? { + for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { + return path + } + return nil + } + + private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { + let globalPrefix = "/Applications/" + let userPrefix = "\(environment.homeDirectoryPath)/Applications/" + var expanded: [String] = [] + + for candidate in applicationBundlePathCandidates { + expanded.append(candidate) + if candidate.hasPrefix(globalPrefix) { + let suffix = String(candidate.dropFirst(globalPrefix.count)) + expanded.append(userPrefix + suffix) + } + } + + return uniquePreservingOrder(expanded) + } + + private var applicationBundlePathCandidates: [String] { + switch self { + case .androidStudio: + return ["/Applications/Android Studio.app"] + case .antigravity: + return ["/Applications/Antigravity.app"] + case .cursor: + return [ + "/Applications/Cursor.app", + "/Applications/Cursor Preview.app", + "/Applications/Cursor Nightly.app", + ] + case .finder: + return ["/System/Library/CoreServices/Finder.app"] + case .ghostty: + return ["/Applications/Ghostty.app"] + case .iterm2: + return [ + "/Applications/iTerm.app", + "/Applications/iTerm2.app", + ] + case .terminal: + return ["/System/Applications/Utilities/Terminal.app"] + case .tower: + return ["/Applications/Tower.app"] + case .vscode: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .warp: + return ["/Applications/Warp.app"] + case .windsurf: + return ["/Applications/Windsurf.app"] + case .xcode: + return ["/Applications/Xcode.app"] + case .zed: + return [ + "/Applications/Zed.app", + "/Applications/Zed Preview.app", + "/Applications/Zed Nightly.app", + ] + } + } + + private func uniquePreservingOrder(_ paths: [String]) -> [String] { + var seen: Set<String> = [] + var deduped: [String] = [] + for path in paths where seen.insert(path).inserted { + deduped.append(path) + } + return deduped + } +} + +enum VSCodeServeWebURLBuilder { + static func extractWebUIURL(from output: String) -> URL? { + let prefix = "Web UI available at " + for line in output.split(whereSeparator: \.isNewline).reversed() { + guard let range = line.range(of: prefix) else { continue } + let rawURL = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawURL.isEmpty, let url = URL(string: rawURL) else { continue } + return url + } + return nil + } + + static func openFolderURL(baseWebUIURL: URL, directoryPath: String) -> URL? { + var components = URLComponents(url: baseWebUIURL, resolvingAgainstBaseURL: false) + var queryItems = components?.queryItems ?? [] + queryItems.removeAll { $0.name == "folder" } + queryItems.append(URLQueryItem(name: "folder", value: directoryPath)) + components?.queryItems = queryItems + return components?.url + } +} + +struct VSCodeCLILaunchConfiguration { + let executableURL: URL + let argumentsPrefix: [String] + let environment: [String: String] +} + +enum VSCodeCLILaunchConfigurationBuilder { + static func launchConfiguration( + vscodeApplicationURL: URL, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, + isExecutableAtPath: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } + ) -> VSCodeCLILaunchConfiguration? { + let contentsURL = vscodeApplicationURL.appendingPathComponent("Contents", isDirectory: true) + let codeTunnelURL = contentsURL.appendingPathComponent("Resources/app/bin/code-tunnel", isDirectory: false) + guard isExecutableAtPath(codeTunnelURL.path) else { return nil } + + var environment = baseEnvironment + environment["ELECTRON_RUN_AS_NODE"] = "1" + environment.removeValue(forKey: "VSCODE_NODE_OPTIONS") + environment.removeValue(forKey: "VSCODE_NODE_REPL_EXTERNAL_MODULE") + if let nodeOptions = environment["NODE_OPTIONS"] { + environment["VSCODE_NODE_OPTIONS"] = nodeOptions + } + if let nodeReplExternalModule = environment["NODE_REPL_EXTERNAL_MODULE"] { + environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"] = nodeReplExternalModule + } + environment.removeValue(forKey: "NODE_OPTIONS") + environment.removeValue(forKey: "NODE_REPL_EXTERNAL_MODULE") + + return VSCodeCLILaunchConfiguration( + executableURL: codeTunnelURL, + argumentsPrefix: [], + environment: environment + ) + } +} + +final class VSCodeServeWebController { + static let shared = VSCodeServeWebController() + private static let serveWebStartupTimeoutSeconds: TimeInterval = 60 + + private let queue = DispatchQueue(label: "cmux.vscode.serveWeb") + private let launchQueue = DispatchQueue(label: "cmux.vscode.serveWeb.launch") + private let launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? + private var serveWebProcess: Process? + private var launchingProcess: Process? + private var connectionTokenFilesByProcessID: [ObjectIdentifier: URL] = [:] + private var serveWebURL: URL? + private var pendingCompletions: [(generation: UInt64, completion: (URL?) -> Void)] = [] + private var isLaunching = false + private var activeLaunchGeneration: UInt64? + private var lifecycleGeneration: UInt64 = 0 +#if DEBUG + private var testingTrackedProcesses: [Process] = [] +#endif + + private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) { + self.launchProcessOverride = launchProcessOverride + } + +#if DEBUG + static func makeForTesting( + launchProcessOverride: @escaping (URL, UInt64) -> (process: Process, url: URL)? + ) -> VSCodeServeWebController { + VSCodeServeWebController(launchProcessOverride: launchProcessOverride) + } + + func trackConnectionTokenFileForTesting( + _ connectionTokenFileURL: URL, + setAsLaunchingProcess: Bool = false, + setAsServeWebProcess: Bool = false + ) { + let process = Process() + queue.sync { + if setAsLaunchingProcess { + self.launchingProcess = process + } + if setAsServeWebProcess { + self.serveWebProcess = process + } + if !setAsLaunchingProcess && !setAsServeWebProcess { + self.testingTrackedProcesses.append(process) + } + self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + } + } +#endif + + func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { + queue.async { + if let process = self.serveWebProcess, + process.isRunning, + let url = self.serveWebURL { + DispatchQueue.main.async { + completion(url) + } + return + } + + let completionGeneration = self.lifecycleGeneration + self.pendingCompletions.append((generation: completionGeneration, completion: completion)) + guard !self.isLaunching else { return } + + self.isLaunching = true + let launchGeneration = completionGeneration + self.activeLaunchGeneration = launchGeneration + + self.launchQueue.async { + let shouldLaunch = self.queue.sync { + self.lifecycleGeneration == launchGeneration + } + guard shouldLaunch else { + self.queue.async { + guard self.activeLaunchGeneration == launchGeneration else { return } + self.isLaunching = false + self.activeLaunchGeneration = nil + } + return + } + let launchResult = self.launchServeWebProcess( + vscodeApplicationURL: vscodeApplicationURL, + expectedGeneration: launchGeneration + ) + self.queue.async { + guard self.activeLaunchGeneration == launchGeneration else { + if let process = launchResult?.process, process.isRunning { + process.terminate() + } + return + } + self.isLaunching = false + self.activeLaunchGeneration = nil + + guard self.lifecycleGeneration == launchGeneration else { + if let launchedProcess = launchResult?.process, + self.launchingProcess === launchedProcess { + self.launchingProcess = nil + } + if let process = launchResult?.process, process.isRunning { + process.terminate() + } + return + } + + if let launchResult { + self.launchingProcess = nil + self.serveWebProcess = launchResult.process + self.serveWebURL = launchResult.url + } else { + self.launchingProcess = nil + self.serveWebProcess = nil + self.serveWebURL = nil + } + + var completions: [(URL?) -> Void] = [] + var remaining: [(generation: UInt64, completion: (URL?) -> Void)] = [] + for pending in self.pendingCompletions { + if pending.generation == launchGeneration { + completions.append(pending.completion) + } else { + remaining.append(pending) + } + } + self.pendingCompletions = remaining + let resolvedURL = self.serveWebURL + DispatchQueue.main.async { + completions.forEach { $0(resolvedURL) } + } + } + } + } + } + + func stop() { + let (processes, tokenFileURLs, completions): ([Process], [URL], [(URL?) -> Void]) = queue.sync { + self.lifecycleGeneration &+= 1 + self.isLaunching = false + self.activeLaunchGeneration = nil + var processes: [Process] = [] + if let process = self.serveWebProcess { + processes.append(process) + } + if let process = self.launchingProcess, + !processes.contains(where: { $0 === process }) { + processes.append(process) + } + self.serveWebProcess = nil + self.launchingProcess = nil +#if DEBUG + self.testingTrackedProcesses.removeAll() +#endif + var tokenFileURLs = processes.compactMap { + self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0)) + } + tokenFileURLs.append(contentsOf: self.connectionTokenFilesByProcessID.values) + self.connectionTokenFilesByProcessID.removeAll() + self.serveWebURL = nil + let completions = self.pendingCompletions.map(\.completion) + self.pendingCompletions.removeAll() + return (processes, tokenFileURLs, completions) + } + + for tokenFileURL in tokenFileURLs { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + + for process in processes where process.isRunning { + process.terminate() + } + + if !completions.isEmpty { + DispatchQueue.main.async { + completions.forEach { $0(nil) } + } + } + } + + func restart(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { + stop() + ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL, completion: completion) + } + + private func launchServeWebProcess( + vscodeApplicationURL: URL, + expectedGeneration: UInt64 + ) -> (process: Process, url: URL)? { + if let launchProcessOverride { + return launchProcessOverride(vscodeApplicationURL, expectedGeneration) + } + + guard let launchConfiguration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: vscodeApplicationURL + ) else { return nil } + + guard let connectionTokenFileURL = Self.makeConnectionTokenFile() else { + return nil + } + + let process = Process() + process.executableURL = launchConfiguration.executableURL + process.arguments = launchConfiguration.argumentsPrefix + [ + "serve-web", + "--accept-server-license-terms", + "--host", "127.0.0.1", + "--port", "0", + "--connection-token-file", connectionTokenFileURL.path, + ] + process.environment = launchConfiguration.environment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let collector = ServeWebOutputCollector() + let outputReader: (FileHandle) -> Void = { fileHandle in + let data = fileHandle.availableData + guard !data.isEmpty else { return } + collector.append(data) + } + stdoutPipe.fileHandleForReading.readabilityHandler = outputReader + stderrPipe.fileHandleForReading.readabilityHandler = outputReader + + process.terminationHandler = { [weak self] terminatedProcess in + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + Self.drainAvailableOutput(from: stdoutPipe.fileHandleForReading, collector: collector) + Self.drainAvailableOutput(from: stderrPipe.fileHandleForReading, collector: collector) + collector.markProcessExited() + self?.queue.async { + guard let self else { return } + if self.launchingProcess === terminatedProcess { + self.launchingProcess = nil + } + if self.serveWebProcess === terminatedProcess { + self.serveWebProcess = nil + self.serveWebURL = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(terminatedProcess) + ) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + } + } + + let didStart: Bool = queue.sync { + guard self.lifecycleGeneration == expectedGeneration, + self.activeLaunchGeneration == expectedGeneration else { + return false + } + self.launchingProcess = process + self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + do { + try process.run() + return true + } catch { + if self.launchingProcess === process { + self.launchingProcess = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(process) + ) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + return false + } + } + guard didStart else { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + Self.removeConnectionTokenFile(at: connectionTokenFileURL) + return nil + } + + guard collector.waitForURL(timeoutSeconds: Self.serveWebStartupTimeoutSeconds), + let serveWebURL = collector.webUIURL else { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + if process.isRunning { + process.terminate() + } else { + queue.sync { + if self.launchingProcess === process { + self.launchingProcess = nil + } + if self.serveWebProcess === process { + self.serveWebProcess = nil + self.serveWebURL = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(process) + ) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + } + } + return nil + } + + return (process, serveWebURL) + } + + private static func drainAvailableOutput(from fileHandle: FileHandle, collector: ServeWebOutputCollector) { + while true { + let data = fileHandle.availableData + guard !data.isEmpty else { return } + collector.append(data) + } + } + + private static func randomConnectionToken() -> String { + UUID().uuidString.replacingOccurrences(of: "-", with: "") + } + + private static func makeConnectionTokenFile() -> URL? { + let token = randomConnectionToken() + let tokenFileName = "cmux-vscode-token-\(UUID().uuidString)" + let tokenFileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(tokenFileName, isDirectory: false) + guard let tokenData = token.data(using: .utf8) else { return nil } + + let fileDescriptor = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) + guard fileDescriptor >= 0 else { return nil } + defer { _ = close(fileDescriptor) } + + let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return false } + return write(fileDescriptor, baseAddress, rawBuffer.count) == rawBuffer.count + } + guard wroteAllBytes else { + removeConnectionTokenFile(at: tokenFileURL) + return nil + } + + return tokenFileURL + } + + private static func removeConnectionTokenFile(at url: URL) { + try? FileManager.default.removeItem(at: url) + } +} + +final class ServeWebOutputCollector { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var outputBuffer = "" + private var resolvedURL: URL? + private var didSignal = false + + var webUIURL: URL? { + lock.lock() + defer { lock.unlock() } + return resolvedURL + } + + func append(_ data: Data) { + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } + lock.lock() + defer { lock.unlock() } + guard resolvedURL == nil else { return } + outputBuffer.append(text) + while let newlineIndex = outputBuffer.firstIndex(where: \.isNewline) { + let line = String(outputBuffer[..<newlineIndex]) + outputBuffer.removeSubrange(...newlineIndex) + guard let parsedURL = VSCodeServeWebURLBuilder.extractWebUIURL(from: line) else { + continue + } + resolvedURL = parsedURL + outputBuffer.removeAll(keepingCapacity: false) + if !didSignal { + didSignal = true + semaphore.signal() + } + return + } + } + + func markProcessExited() { + lock.lock() + defer { lock.unlock() } + if resolvedURL == nil, !outputBuffer.isEmpty, + let parsedURL = VSCodeServeWebURLBuilder.extractWebUIURL(from: outputBuffer) { + resolvedURL = parsedURL + outputBuffer.removeAll(keepingCapacity: false) + } + guard !didSignal else { return } + didSignal = true + semaphore.signal() + } + + func waitForURL(timeoutSeconds: TimeInterval) -> Bool { + if webUIURL != nil { return true } + _ = semaphore.wait(timeout: .now() + timeoutSeconds) + return webUIURL != nil + } +} + enum WorkspaceShortcutMapper { /// Maps Cmd+digit workspace shortcuts to a zero-based workspace index. /// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace. @@ -64,16 +1066,338 @@ enum WorkspaceShortcutMapper { } } +struct CmuxCLIPathInstaller { + struct InstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let sourceURL: URL + } + + struct UninstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let removedExistingEntry: Bool + } + + enum InstallerError: LocalizedError { + case bundledCLIMissing(expectedPath: String) + case destinationParentNotDirectory(path: String) + case destinationIsDirectory(path: String) + case installVerificationFailed(path: String) + case uninstallVerificationFailed(path: String) + case privilegedCommandFailed(message: String) + + var errorDescription: String? { + switch self { + case .bundledCLIMissing(let expectedPath): + return "Bundled cmux CLI was not found at \(expectedPath)." + case .destinationParentNotDirectory(let path): + return "Expected \(path) to be a directory." + case .destinationIsDirectory(let path): + return "\(path) is a directory. Remove or rename it and try again." + case .installVerificationFailed(let path): + return "Installed symlink at \(path) did not point to the bundled cmux CLI." + case .uninstallVerificationFailed(let path): + return "Failed to remove \(path)." + case .privilegedCommandFailed(let message): + return "Administrator action failed: \(message)" + } + } + } + + typealias PrivilegedInstallHandler = (_ sourceURL: URL, _ destinationURL: URL) throws -> Void + typealias PrivilegedUninstallHandler = (_ destinationURL: URL) throws -> Void + + let fileManager: FileManager + let destinationURL: URL + private let bundledCLIURLProvider: () -> URL? + private let expectedBundledCLIPath: String + private let privilegedInstaller: PrivilegedInstallHandler + private let privilegedUninstaller: PrivilegedUninstallHandler + + init( + fileManager: FileManager = .default, + destinationURL: URL = URL(fileURLWithPath: "/usr/local/bin/cmux"), + bundledCLIURLProvider: @escaping () -> URL? = { + CmuxCLIPathInstaller.defaultBundledCLIURL() + }, + expectedBundledCLIPath: String = CmuxCLIPathInstaller.defaultBundledCLIExpectedPath(), + privilegedInstaller: PrivilegedInstallHandler? = nil, + privilegedUninstaller: PrivilegedUninstallHandler? = nil + ) { + self.fileManager = fileManager + self.destinationURL = destinationURL + self.bundledCLIURLProvider = bundledCLIURLProvider + self.expectedBundledCLIPath = expectedBundledCLIPath + self.privilegedInstaller = privilegedInstaller ?? Self.installWithAdministratorPrivileges(sourceURL:destinationURL:) + self.privilegedUninstaller = privilegedUninstaller ?? Self.uninstallWithAdministratorPrivileges(destinationURL:) + } + + var destinationPath: String { + destinationURL.path + } + + func install() throws -> InstallOutcome { + let sourceURL = try resolveBundledCLIURL() + do { + try installWithoutAdministratorPrivileges(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + try privilegedInstaller(sourceURL, destinationURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } + } + + func uninstall() throws -> UninstallOutcome { + do { + let removedExistingEntry = try uninstallWithoutAdministratorPrivileges() + return UninstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + let removedExistingEntry = destinationEntryExists() + try privilegedUninstaller(destinationURL) + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return UninstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } + } + + func isInstalled() -> Bool { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { return false } + guard let installedTargetURL = symlinkDestinationURL() else { return false } + return installedTargetURL == sourceURL + } + + private func resolveBundledCLIURL() throws -> URL { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { + throw InstallerError.bundledCLIMissing(expectedPath: expectedBundledCLIPath) + } + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path) + } + return sourceURL + } + + private func installWithoutAdministratorPrivileges(sourceURL: URL) throws { + try ensureDestinationParentDirectoryExists() + try ensureDestinationIsNotDirectory() + if destinationEntryExists() { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + } + + @discardableResult + private func uninstallWithoutAdministratorPrivileges() throws -> Bool { + try ensureDestinationIsNotDirectory() + let existed = destinationEntryExists() + if existed { + try fileManager.removeItem(at: destinationURL) + } + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return existed + } + + /// Check if the destination path has any filesystem entry (including dangling symlinks). + /// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false. + private func destinationEntryExists() -> Bool { + (try? fileManager.attributesOfItem(atPath: destinationURL.path)) != nil + } + + private func verifyInstalledSymlinkTarget(sourceURL: URL) throws { + guard let installedTargetURL = symlinkDestinationURL(), + installedTargetURL == sourceURL.standardizedFileURL else { + throw InstallerError.installVerificationFailed(path: destinationURL.path) + } + } + + private func symlinkDestinationURL() -> URL? { + guard fileManager.fileExists(atPath: destinationURL.path) else { return nil } + guard let destinationPath = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else { + return nil + } + return URL( + fileURLWithPath: destinationPath, + relativeTo: destinationURL.deletingLastPathComponent() + ).standardizedFileURL + } + + private func ensureDestinationParentDirectoryExists() throws { + let parentURL = destinationURL.deletingLastPathComponent() + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: parentURL.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw InstallerError.destinationParentNotDirectory(path: parentURL.path) + } + return + } + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) + } + + private func ensureDestinationIsNotDirectory() throws { + guard let values = try resourceValuesIfFileExists( + at: destinationURL, + keys: [.isDirectoryKey, .isSymbolicLinkKey] + ) else { + return + } + + if values.isDirectory == true, values.isSymbolicLink != true { + throw InstallerError.destinationIsDirectory(path: destinationURL.path) + } + } + + private func resourceValuesIfFileExists( + at url: URL, + keys: Set<URLResourceKey> + ) throws -> URLResourceValues? { + do { + return try url.resourceValues(forKeys: keys) + } catch { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError { + return nil + } + if nsError.domain == NSPOSIXErrorDomain, + POSIXErrorCode(rawValue: Int32(nsError.code)) == .ENOENT { + return nil + } + throw error + } + } + + private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? { + bundle.resourceURL?.appendingPathComponent("bin/cmux", isDirectory: false) + } + + private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String { + bundle.bundleURL + .appendingPathComponent("Contents/Resources/bin/cmux", isDirectory: false) + .path + } + + private static func installWithAdministratorPrivileges(sourceURL: URL, destinationURL: URL) throws { + let destinationPath = destinationURL.path + let parentPath = destinationURL.deletingLastPathComponent().path + let command = "/bin/mkdir -p \(shellQuoted(parentPath)) && " + + "/bin/rm -f \(shellQuoted(destinationPath)) && " + + "/bin/ln -s \(shellQuoted(sourceURL.path)) \(shellQuoted(destinationPath))" + try runPrivilegedShellCommand(command) + } + + private static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws { + let command = "/bin/rm -f \(shellQuoted(destinationURL.path))" + try runPrivilegedShellCommand(command) + } + + private static func runPrivilegedShellCommand(_ command: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = [ + "-e", "on run argv", + "-e", "do shell script (item 1 of argv) with administrator privileges", + "-e", "end run", + command + ] + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let stderrText = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stdoutText = String( + data: stdout.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let details = stderrText.isEmpty ? stdoutText : stderrText + let message = details.isEmpty + ? "osascript exited with status \(process.terminationStatus)." + : details + throw InstallerError.privilegedCommandFailed(message: message) + } + } + + private static func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func isPermissionDenied(_ error: Error) -> Bool { + isPermissionDenied(error as NSError) + } + + private static func isPermissionDenied(_ error: NSError) -> Bool { + if error.domain == NSPOSIXErrorDomain, + let code = POSIXErrorCode(rawValue: Int32(error.code)), + code == .EACCES || code == .EPERM || code == .EROFS { + return true + } + + if error.domain == NSCocoaErrorDomain { + switch error.code { + case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError: + return true + default: + break + } + } + + if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { + return isPermissionDenied(underlying) + } + + return false + } +} + +private extension NSScreen { + var cmuxDisplayID: UInt32? { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let value = deviceDescription[key] as? NSNumber else { return nil } + return value.uint32Value + } +} + func browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: Bool, flags: NSEvent.ModifierFlags, chars: String ) -> Int? { guard hasFocusedAddressBar else { return nil } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags == [.control] else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return nil } if chars == "n" { return 1 } if chars == "p" { return -1 } return nil @@ -85,9 +1409,7 @@ func browserOmnibarSelectionDeltaForArrowNavigation( keyCode: UInt16 ) -> Int? { guard hasFocusedAddressBar else { return nil } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) guard normalizedFlags == [] else { return nil } switch keyCode { case 125: return 1 @@ -96,56 +1418,417 @@ func browserOmnibarSelectionDeltaForArrowNavigation( } } +func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { + flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) +} + func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + return normalizedFlags == [] || normalizedFlags == [.shift] +} + +func shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsBrowser: Bool, + flags: NSEvent.ModifierFlags +) -> Bool { + guard firstResponderIsBrowser else { return false } + guard keyCode == 36 || keyCode == 76 else { return false } + // Keep browser Return forwarding narrow: only plain/Shift Return should be + // treated as submit-intent. Command-modified Return is reserved for app shortcuts + // like Toggle Pane Zoom (Cmd+Shift+Enter). + return browserOmnibarShouldSubmitOnReturn(flags: flags) +} + +func shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalizedFlags == [.command, .control] else { return false } + let normalizedChars = chars.lowercased() + if normalizedChars == "f" { + return true + } + let charsAreControlSequence = !normalizedChars.isEmpty + && normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) } + if !normalizedChars.isEmpty && !charsAreControlSequence { + return false + } + + // Fallback to layout translation only when characters are unavailable (for + // synthetic/key-equivalent paths that can report an empty string). + if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty { + return translatedCharacter == "f" + } + + // Keep ANSI fallback as a final safety net when layout translation is unavailable. + return keyCode == 3 +} + +func commandPaletteSelectionDeltaForKeyboardNavigation( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Int? { let normalizedFlags = flags .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function]) + let normalizedChars = chars.lowercased() + + if normalizedFlags == [] { + switch keyCode { + case 125: return 1 // Down arrow + case 126: return -1 // Up arrow + default: break + } + } + + if normalizedFlags == [.control] { + // Control modifiers can surface as either printable chars or ASCII control chars. + if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N + if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P + if keyCode == 38 || normalizedChars == "j" || normalizedChars == "\u{0a}" { return 1 } // Ctrl+J + if keyCode == 40 || normalizedChars == "k" || normalizedChars == "\u{0b}" { return -1 } // Ctrl+K + } + + return nil +} + +func shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: Bool, + normalizedFlags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + guard isCommandPaletteVisible else { return false } + + // Escape dismisses the palette, and must not leak through to the + // underlying terminal or browser content. + if normalizedFlags.isEmpty, keyCode == 53 { + return true + } + + guard normalizedFlags.contains(.command) else { return false } + + let normalizedChars = chars.lowercased() + + if normalizedFlags == [.command] { + if normalizedChars == "a" + || normalizedChars == "c" + || normalizedChars == "v" + || normalizedChars == "x" + || normalizedChars == "z" + || normalizedChars == "y" { + return false + } + + switch keyCode { + case 51, 117, 123, 124: + return false + default: + break + } + } + + if normalizedFlags == [.command, .shift], normalizedChars == "z" { + return false + } + + return true +} + +func shouldSubmitCommandPaletteWithReturn( + keyCode: UInt16, + flags: NSEvent.ModifierFlags +) -> Bool { + guard keyCode == 36 || keyCode == 76 else { return false } + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) return normalizedFlags == [] || normalizedFlags == [.shift] } +func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool { + guard let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor else { + return false + } + return editor.hasMarkedText() +} + +func shouldHandleCommandPaletteShortcutEvent( + _ event: NSEvent, + paletteWindow: NSWindow? +) -> Bool { + guard let paletteWindow else { return false } + if let eventWindow = event.window { + return eventWindow === paletteWindow + } + let eventWindowNumber = event.windowNumber + if eventWindowNumber > 0 { + return eventWindowNumber == paletteWindow.windowNumber + } + if let keyWindow = NSApp.keyWindow { + return keyWindow === paletteWindow + } + return false +} + enum BrowserZoomShortcutAction: Equatable { case zoomIn case zoomOut case reset } +struct CommandPaletteDebugResultRow { + let commandId: String + let title: String + let shortcutHint: String? + let trailingLabel: String? + let score: Int +} + +struct CommandPaletteDebugSnapshot { + let query: String + let mode: String + let results: [CommandPaletteDebugResultRow] + + static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) +} + func browserZoomShortcutAction( flags: NSEvent.ModifierFlags, chars: String, - keyCode: UInt16 + keyCode: UInt16, + literalChars: String? = nil ) -> BrowserZoomShortcutAction? { let normalizedFlags = flags .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function]) - let key = chars.lowercased() + let hasCommand = normalizedFlags.contains(.command) + let hasOnlyCommandAndOptionalShift = hasCommand && normalizedFlags.isDisjoint(with: [.control, .option]) - if normalizedFlags == [.command] { - if key == "=" || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus - return .zoomIn - } - if key == "-" || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus - return .zoomOut - } - if key == "0" || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 - return .reset - } - } + guard hasOnlyCommandAndOptionalShift else { return nil } + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) - if normalizedFlags == [.command, .shift] && (key == "=" || key == "+" || keyCode == 24 || keyCode == 69) { + if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus return .zoomIn } + if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus + return .zoomOut + } + + if keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 + return .reset + } + return nil } +func browserZoomShortcutKeyCandidates( + chars: String, + literalChars: String?, + keyCode: UInt16 +) -> Set<String> { + var keys: Set<String> = [chars.lowercased()] + + if let literalChars, !literalChars.isEmpty { + keys.insert(literalChars.lowercased()) + } + + if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { + keys.insert(layoutChar) + } + + return keys +} + +func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: Bool, + hostedSize: CGSize, + hostedHiddenInHierarchy: Bool, + hostedAttachedToWindow: Bool +) -> Bool { + guard firstResponderIsWindow else { return false } + let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 + return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow +} + +func shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: Bool, + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> Bool { + guard firstResponderIsGhostty else { return false } + return browserZoomShortcutAction( + flags: flags, + chars: chars, + keyCode: keyCode, + literalChars: literalChars + ) != nil +} + +/// Let AppKit own native Cmd+` window cycling so key-window changes do not +/// re-enter our direct-to-menu shortcut path. +func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command) else { return false } + + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + if event.keyCode == 50, + normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { + return false + } + + return true +} + +func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { + guard let responder else { return nil } + if let ghosttyView = responder as? GhosttyNSView { + return ghosttyView + } + + if let view = responder as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView + } + + var current = responder.nextResponder + while let next = current { + if let ghosttyView = next as? GhosttyNSView { + return ghosttyView + } + if let view = next as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + current = next.nextResponder + } + + return nil +} + +private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { + if let ghosttyView = view as? GhosttyNSView { + return ghosttyView + } + + var current: NSView? = view.superview + while let candidate = current { + if let ghosttyView = candidate as? GhosttyNSView { + return ghosttyView + } + current = candidate.superview + } + + return nil +} + +#if DEBUG +func browserZoomShortcutTraceCandidate( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + guard normalizedFlags.contains(.command) else { return false } + + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) + if keys.contains("=") || keys.contains("+") || keys.contains("-") || keys.contains("_") || keys.contains("0") { + return true + } + switch keyCode { + case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys + return true + default: + return false + } +} + +func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + var parts: [String] = [] + if normalizedFlags.contains(.command) { parts.append("Cmd") } + if normalizedFlags.contains(.shift) { parts.append("Shift") } + if normalizedFlags.contains(.option) { parts.append("Opt") } + if normalizedFlags.contains(.control) { parts.append("Ctrl") } + return parts.isEmpty ? "none" : parts.joined(separator: "+") +} + +func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String { + guard let action else { return "none" } + switch action { + case .zoomIn: return "zoomIn" + case .zoomOut: return "zoomOut" + case .reset: return "reset" + } +} +#endif + +func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { + var candidate = hitView + while let view = candidate { + if view is DraggableFolderNSView { + return true + } + candidate = view.superview + } + return false +} + +func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + window.isMovable, + let contentView = window.contentView else { + return false + } + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) +} + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { static var shared: AppDelegate? - private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { - // On some macOS/Xcode setups, the app-under-test process doesn't get - // `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests - // can reliably skip heavyweight startup work and bring up a window. + private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) + + private var isRunningUnderXCTestCached: Bool { + Self.cachedIsRunningUnderXCTest + } + + private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { if env["XCTestConfigurationFilePath"] != nil { return true } if env["XCTestBundlePath"] != nil { return true } if env["XCTestSessionIdentifier"] != nil { return true } @@ -156,6 +1839,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { + // On some macOS/Xcode setups, the app-under-test process doesn't get + // `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests + // can reliably skip heavyweight startup work and bring up a window. + Self.detectRunningUnderXCTest(env) + } + private final class MainWindowContext { let windowId: UUID let tabManager: TabManager @@ -186,12 +1876,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + struct ScriptableMainWindowState { + let windowId: UUID + let tabManager: TabManager + let window: NSWindow? + } + + struct SessionDisplayGeometry { + let displayID: UInt32? + let frame: CGRect + let visibleFrame: CGRect + } + + private struct PersistedWindowGeometry: Codable, Sendable { + let frame: SessionRectSnapshot + let display: SessionDisplaySnapshot? + } + + private static let persistedWindowGeometryDefaultsKey = "cmux.session.lastWindowGeometry.v1" + weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? weak var sidebarState: SidebarState? weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? + var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) private var workspaceObserver: NSObjectProtocol? + private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] private var windowKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? private var shortcutDefaultsObserver: NSObjectProtocol? @@ -212,7 +1923,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) private let windowDecorationsController = WindowDecorationsController() private var menuBarExtraController: MenuBarExtraController? - private static let serviceErrorNoPath = NSString(string: "Could not load any folder path from the clipboard.") + private static let serviceErrorNoPath = NSString(string: String(localized: "error.clipboardFolderPath", defaultValue: "Could not load any folder path from the clipboard.")) private static let didInstallWindowKeyEquivalentSwizzle: Void = { let targetClass: AnyClass = NSWindow.self let originalSelector = #selector(NSWindow.performKeyEquivalent(with:)) @@ -223,6 +1934,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } method_exchangeImplementations(originalMethod, swizzledMethod) }() + private static let didInstallWindowFirstResponderSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) + let swizzledSelector = #selector(NSWindow.cmux_makeFirstResponder(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallWindowSendEventSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.sendEvent(_:)) + let swizzledSelector = #selector(NSWindow.cmux_sendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallApplicationSendEventSwizzle: Void = { + let targetClass: AnyClass = NSApplication.self + let originalSelector = #selector(NSApplication.sendEvent(_:)) + let swizzledSelector = #selector(NSApplication.cmux_applicationSendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() #if DEBUG private var didSetupJumpUnreadUITest = false @@ -231,6 +1972,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupGotoSplitUITest = false private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false + var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? + // Keep debug-only windows alive when tests intentionally inject key mismatches. + private var debugDetachedContextWindows: [NSWindow] = [] private func childExitKeyboardProbePath() -> String? { let env = ProcessInfo.processInfo.environment @@ -272,11 +2016,98 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] private var mainWindowControllers: [MainWindowController] = [] + private var startupSessionSnapshot: AppSessionSnapshot? + private var didPrepareStartupSessionSnapshot = false + private var didAttemptStartupSessionRestore = false + private var isApplyingStartupSessionRestore = false + private var sessionAutosaveTimer: DispatchSourceTimer? + private var sessionAutosaveTickInFlight = false + private var sessionAutosaveDeferredRetryPending = false + private var socketListenerHealthTimer: DispatchSourceTimer? + private var socketListenerHealthCheckInFlight = false + private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2) + private var lastSocketListenerUnhealthyCaptureAt: Date = .distantPast + private static let socketListenerUnhealthyCaptureCooldown: TimeInterval = 60 + private let sessionPersistenceQueue = DispatchQueue( + label: "com.cmuxterm.app.sessionPersistence", + qos: .utility + ) + private nonisolated static let launchServicesRegistrationQueue = DispatchQueue( + label: "com.cmuxterm.app.launchServicesRegistration", + qos: .utility + ) + private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { + launchServicesRegistrationQueue.async(execute: work) + } + private var lastSessionAutosaveFingerprint: Int? + private var lastSessionAutosavePersistedAt: Date = .distantPast + private var lastTypingActivityAt: TimeInterval = 0 + private var didHandleExplicitOpenIntentAtStartup = false + private var isTerminatingApp = false + private var didInstallLifecycleSnapshotObservers = false + private var didDisableSuddenTermination = false + private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] + private var commandPalettePendingOpenByWindowId: [UUID: Bool] = [:] + private var commandPaletteRecentRequestAtByWindowId: [UUID: TimeInterval] = [:] + private var commandPaletteEscapeSuppressionByWindowId: Set<UUID> = [] + private var commandPaletteEscapeSuppressionStartedAtByWindowId: [UUID: TimeInterval] = [:] + private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] + private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] + private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 + private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 + private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 var updateViewModel: UpdateViewModel { updateController.viewModel } +#if DEBUG + private func pointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let window = context.window ?? windowForMainWindowId(context.windowId) + let windowNumber = window?.windowNumber ?? -1 + let key = window?.isKeyWindow == true ? 1 : 0 + let main = window?.isMainWindow == true ? 1 : 0 + let visible = window?.isVisible == true ? 1 : 0 + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" + return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" + } + + private func summarizeAllContextsForWorkspaceRouting() -> String { + guard !mainWindowContexts.isEmpty else { return "<none>" } + return mainWindowContexts.values + .map { summarizeContextForWorkspaceRouting($0) } + .joined(separator: " | ") + } + + private func logWorkspaceCreationRouting( + phase: String, + source: String, + reason: String, + event: NSEvent?, + chosenContext: MainWindowContext?, + workspaceId: UUID? = nil, + workingDirectory: String? = nil + ) { + let eventWindowNumber = event?.window?.windowNumber ?? -1 + let eventNumber = event?.windowNumber ?? -1 + let eventChars = event?.charactersIgnoringModifiers ?? "" + let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" + let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 + let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 + let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" + let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" + FocusLogStore.shared.append( + "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" + ) + } +#endif + override init() { super.init() Self.shared = self @@ -285,6 +2116,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationDidFinishLaunching(_ notification: Notification) { let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) + let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch #if DEBUG // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make @@ -296,37 +2128,68 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG writeUITestDiagnosticsIfNeeded(stage: "didFinishLaunching") + CmuxMainRunLoopStallMonitor.shared.installIfNeeded() + CmuxMainThreadTurnProfiler.shared.installIfNeeded() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.writeUITestDiagnosticsIfNeeded(stage: "after1s") } #endif - SentrySDK.start { options in - options.dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" - #if DEBUG - options.environment = "development" - options.debug = true - #else - options.environment = "production" - options.debug = false - #endif - options.sendDefaultPii = true + if telemetryEnabled { + // Pre-warm locale before Sentry to avoid a startup data race. + // Locale initialization (os.locale.ensureLocale / NSLocale._preferredLanguages) + // on the main thread can race with Sentry's background init thread + // calling posix.getenv, causing a SIGSEGV ~134ms after launch. + // Forcing locale access here before SentrySDK.start eliminates the race. + // Related to: #836 + _ = Locale.current + _ = NSLocale.preferredLanguages + + SentrySDK.start { options in + options.dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" + #if DEBUG + options.environment = "development" + options.debug = true + #else + options.environment = "production" + options.debug = false + #endif + options.sendDefaultPii = false + + // Performance tracing (10% of transactions) + options.tracesSampleRate = 0.1 + // Keep app-hang tracking enabled, but avoid reporting short main-thread stalls + // as hangs in normal user interaction flows. + options.appHangTimeoutInterval = 8.0 + // Attach stack traces to all events + options.attachStacktrace = true + // Avoid recursively capturing failed requests from Sentry's own ingestion endpoint. + options.enableCaptureFailedRequests = false + } } - if !isRunningUnderXCTest { + if telemetryEnabled && !isRunningUnderXCTest { PostHogAnalytics.shared.startIfNeeded() } + let forceDuplicateLaunchObserver = env["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" + // UI tests frequently time out waiting for the main window if we do heavyweight // LaunchServices registration / single-instance enforcement synchronously at startup. // Skip these during XCTest (the app-under-test) so the window can appear quickly. if !isRunningUnderXCTest { DispatchQueue.main.async { [weak self] in guard let self else { return } - self.registerLaunchServicesBundle() + self.scheduleLaunchServicesBundleRegistration() self.enforceSingleInstance() self.observeDuplicateLaunches() } + } else if forceDuplicateLaunchObserver { + // Some UI regressions specifically exercise launch-observer behavior while still + // running under XCTest. Allow an explicit opt-in for those cases only. + DispatchQueue.main.async { [weak self] in + self?.observeDuplicateLaunches() + } } NSWindow.allowsAutomaticWindowTabbing = false disableNativeTabbingShortcut() @@ -342,7 +2205,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installMainWindowKeyObserver() refreshGhosttyGotoSplitShortcuts() installGhosttyConfigObserver() - installWindowKeyEquivalentSwizzle() + installWindowResponderSwizzles() installBrowserAddressBarFocusObservers() installShortcutMonitor() installShortcutDefaultsObserver() @@ -416,12 +2279,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif func applicationDidBecomeActive(_ notification: Notification) { - let env = ProcessInfo.processInfo.environment - if !isRunningUnderXCTest(env) { - PostHogAnalytics.shared.trackDailyActive(reason: "didBecomeActive") + sentryBreadcrumb("app.didBecomeActive", category: "lifecycle", data: [ + "tabCount": tabManager?.tabs.count ?? 0 + ]) + if TelemetrySettings.enabledForCurrentLaunch && !isRunningUnderXCTestCached { + PostHogAnalytics.shared.trackActive(reason: "didBecomeActive") } - guard let tabManager, let notificationStore else { return } + guard let notificationStore else { return } + notificationStore.handleApplicationDidBecomeActive() + guard let tabManager else { return } guard let tabId = tabManager.selectedTabId else { return } let surfaceId = tabManager.focusedSurfaceId(for: tabId) guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } @@ -433,17 +2300,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + return .terminateNow + } + func applicationWillTerminate(_ notification: Notification) { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + stopSessionAutosaveTimer() + stopSocketListenerHealthMonitor() TerminalController.shared.stop() + VSCodeServeWebController.shared.stop() BrowserHistoryStore.shared.flushPendingSaves() - PostHogAnalytics.shared.flush() + if TelemetrySettings.enabledForCurrentLaunch { + PostHogAnalytics.shared.flush() + } notificationStore?.clearAll() + enableSuddenTerminationIfNeeded() + } + + func applicationWillResignActive(_ notification: Notification) { + guard !isTerminatingApp else { return } + _ = saveSessionSnapshot(includeScrollback: false) + } + + func persistSessionForUpdateRelaunch() { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { self.tabManager = tabManager self.notificationStore = notificationStore self.sidebarState = sidebarState + disableSuddenTerminationIfNeeded() + installLifecycleSnapshotObserversIfNeeded() + prepareStartupSessionSnapshotIfNeeded() + startSessionAutosaveTimerIfNeeded() + startSocketListenerHealthMonitorIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() @@ -469,6 +2365,1049 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } + private func prepareStartupSessionSnapshotIfNeeded() { + guard !didPrepareStartupSessionSnapshot else { return } + didPrepareStartupSessionSnapshot = true + guard SessionRestorePolicy.shouldAttemptRestore() else { return } + startupSessionSnapshot = SessionPersistenceStore.load() + } + + private func persistedWindowGeometry( + defaults: UserDefaults = .standard + ) -> PersistedWindowGeometry? { + guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { + return nil + } + return try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data) + } + + private func persistWindowGeometry( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot?, + defaults: UserDefaults = .standard + ) { + guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { + return + } + defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) + } + + private nonisolated static func encodedPersistedWindowGeometryData( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot? + ) -> Data? { + guard let frame else { return nil } + let payload = PersistedWindowGeometry(frame: frame, display: display) + return try? JSONEncoder().encode(payload) + } + + private func persistWindowGeometry(from window: NSWindow?) { + guard let window else { return } + persistWindowGeometry( + frame: SessionRectSnapshot(window.frame), + display: displaySnapshot(for: window) + ) + } + + private func currentDisplayGeometries() -> ( + available: [SessionDisplayGeometry], + fallback: SessionDisplayGeometry? + ) { + let available = NSScreen.screens.map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + return (available, fallback) + } + + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { + guard !didAttemptStartupSessionRestore else { return } + didAttemptStartupSessionRestore = true + guard !didHandleExplicitOpenIntentAtStartup else { return } + guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } + + let startupSnapshot = startupSessionSnapshot + let primaryWindowSnapshot = startupSnapshot?.windows.first + if let primaryWindowSnapshot { + isApplyingStartupSessionRestore = true +#if DEBUG + dlog( + "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + + "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + + "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" + ) +#endif + applySessionWindowSnapshot( + primaryWindowSnapshot, + to: primaryContext, + window: primaryWindow + ) + } else { + let displays = currentDisplayGeometries() + let fallbackGeometry = persistedWindowGeometry() + if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackGeometry?.frame, + fallbackDisplaySnapshot: fallbackGeometry?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) { + primaryWindow.setFrame(restoredFrame, display: true) + } + } + + if let startupSnapshot { + let additionalWindows = Array(startupSnapshot + .windows + .dropFirst() + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) +#if DEBUG + for (index, windowSnapshot) in additionalWindows.enumerated() { + dlog( + "session.restore.enqueueAdditional idx=\(index + 1) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" + ) + } +#endif + if !additionalWindows.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for windowSnapshot in additionalWindows { + _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) + } + self.completeStartupSessionRestore() + } + } else { + completeStartupSessionRestore() + } + } + } + + private func completeStartupSessionRestore() { + startupSessionSnapshot = nil + isApplyingStartupSessionRestore = false + _ = saveSessionSnapshot(includeScrollback: false) + } + + private func applySessionWindowSnapshot( + _ snapshot: SessionWindowSnapshot, + to context: MainWindowContext, + window: NSWindow? + ) { +#if DEBUG + dlog( + "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + + "liveWin=\(window?.windowNumber ?? -1) " + + "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + + "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" + ) +#endif + context.tabManager.restoreSessionSnapshot(snapshot.tabManager) + context.sidebarState.isVisible = snapshot.sidebar.isVisible + context.sidebarState.persistedWidth = CGFloat( + SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + ) + context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection + + if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { + window.setFrame(restoredFrame, display: true) +#if DEBUG + dlog( + "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) +#endif + } + } + + private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { + let displays = currentDisplayGeometries() + return Self.resolvedWindowFrame( + from: snapshot?.frame, + display: snapshot?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) + } + + nonisolated static func resolvedStartupPrimaryWindowFrame( + primarySnapshot: SessionWindowSnapshot?, + fallbackFrame: SessionRectSnapshot?, + fallbackDisplaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + if let primary = resolvedWindowFrame( + from: primarySnapshot?.frame, + display: primarySnapshot?.display, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay + ) { + return primary + } + + return resolvedWindowFrame( + from: fallbackFrame, + display: fallbackDisplaySnapshot, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay + ) + } + + nonisolated static func resolvedWindowFrame( + from frameSnapshot: SessionRectSnapshot?, + display displaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + guard let frameSnapshot else { return nil } + let frame = frameSnapshot.cgRect + guard frame.width.isFinite, + frame.height.isFinite, + frame.origin.x.isFinite, + frame.origin.y.isFinite else { + return nil + } + + let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) + let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) + guard frame.width >= minWidth, + frame.height >= minHeight else { + return nil + } + + guard !availableDisplays.isEmpty else { return frame } + + if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { + if shouldPreserveExactFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay + ) { + return frame + } + return resolvedWindowFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay, + minWidth: minWidth, + minHeight: minHeight + ) + } + + if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { + return clampFrame( + frame, + within: intersectingDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + guard let fallbackDisplay else { return frame } + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + return centeredFrame( + frame, + in: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func resolvedWindowFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + if targetDisplay.visibleFrame.intersects(frame) { + return clampFrame( + frame, + within: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + return centeredFrame( + frame, + in: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func display( + for snapshot: SessionDisplaySnapshot?, + in displays: [SessionDisplayGeometry] + ) -> SessionDisplayGeometry? { + guard let snapshot else { return nil } + if let displayID = snapshot.displayID, + let exact = displays.first(where: { $0.displayID == displayID }) { + return exact + } + + guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { + return nil + } + + let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in + (display, intersectionArea(referenceRect, display.visibleFrame)) + } + if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { + return bestOverlap.display + } + + let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) + return displays.min { lhs, rhs in + let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) + let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) + return lhsDistance < rhsDistance + } + } + + private nonisolated static func remappedFrame( + _ frame: CGRect, + from sourceRect: CGRect, + to targetRect: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let source = sourceRect.standardized + let target = targetRect.standardized + guard source.width.isFinite, + source.height.isFinite, + source.width > 1, + source.height > 1, + target.width.isFinite, + target.height.isFinite, + target.width > 0, + target.height > 0 else { + return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) + } + + let relativeX = (frame.minX - source.minX) / source.width + let relativeY = (frame.minY - source.minY) / source.height + let relativeWidth = frame.width / source.width + let relativeHeight = frame.height / source.height + + let remapped = CGRect( + x: target.minX + (relativeX * target.width), + y: target.minY + (relativeY * target.height), + width: target.width * relativeWidth, + height: target.height * relativeHeight + ) + return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) + } + + private nonisolated static func centeredFrame( + _ frame: CGRect, + in visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let centered = CGRect( + x: visibleFrame.midX - (frame.width / 2), + y: visibleFrame.midY - (frame.height / 2), + width: frame.width, + height: frame.height + ) + return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) + } + + private nonisolated static func clampFrame( + _ frame: CGRect, + within visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + guard visibleFrame.width.isFinite, + visibleFrame.height.isFinite, + visibleFrame.width > 0, + visibleFrame.height > 0 else { + return frame + } + + let maxWidth = max(visibleFrame.width, 1) + let maxHeight = max(visibleFrame.height, 1) + let widthFloor = min(minWidth, maxWidth) + let heightFloor = min(minHeight, maxHeight) + + let width = min(max(frame.width, widthFloor), maxWidth) + let height = min(max(frame.height, heightFloor), maxHeight) + let maxX = visibleFrame.maxX - width + let maxY = visibleFrame.maxY - height + let x = min(max(frame.minX, visibleFrame.minX), maxX) + let y = min(max(frame.minY, visibleFrame.minY), maxY) + + return CGRect(x: x, y: y, width: width, height: height) + } + + private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { return 0 } + return max(0, intersection.width) * max(0, intersection.height) + } + + private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { + let dx = rect.midX - point.x + let dy = rect.midY - point.y + return (dx * dx) + (dy * dy) + } + + private nonisolated static func shouldPreserveExactFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry + ) -> Bool { + guard let displaySnapshot else { return false } + guard let snapshotDisplayID = displaySnapshot.displayID, + let targetDisplayID = targetDisplay.displayID, + snapshotDisplayID == targetDisplayID else { + return false + } + + let visibleMatches = displaySnapshot.visibleFrame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame) + } ?? false + let frameMatches = displaySnapshot.frame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.frame) + } ?? false + guard visibleMatches || frameMatches else { return false } + + return frame.width.isFinite + && frame.height.isFinite + && frame.origin.x.isFinite + && frame.origin.y.isFinite + } + + private nonisolated static func rectApproximatelyEqual( + _ lhs: CGRect, + _ rhs: CGRect, + tolerance: CGFloat = 1 + ) -> Bool { + let lhsStd = lhs.standardized + let rhsStd = rhs.standardized + return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance + && abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance + && abs(lhsStd.size.width - rhsStd.size.width) <= tolerance + && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance + } + + private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { + guard let window else { return nil } + let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) + guard let screen else { return nil } + + return SessionDisplaySnapshot( + displayID: screen.cmuxDisplayID, + frame: SessionRectSnapshot(screen.frame), + visibleFrame: SessionRectSnapshot(screen.visibleFrame) + ) + } + + private func startSessionAutosaveTimerIfNeeded() { + guard sessionAutosaveTimer == nil else { return } + let env = ProcessInfo.processInfo.environment + guard !isRunningUnderXCTest(env) else { return } + + let timer = DispatchSource.makeTimerSource(queue: .main) + let interval = SessionPersistencePolicy.autosaveInterval + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + guard let self, + Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { + return + } + self.runSessionAutosaveTick(source: "timer") + } + sessionAutosaveTimer = timer + timer.resume() + } + + private func stopSessionAutosaveTimer() { + sessionAutosaveTimer?.cancel() + sessionAutosaveTimer = nil + sessionAutosaveTickInFlight = false + sessionAutosaveDeferredRetryPending = false + } + + private func installLifecycleSnapshotObserversIfNeeded() { + guard !didInstallLifecycleSnapshotObservers else { return } + didInstallLifecycleSnapshotObservers = true + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let powerOffObserver = workspaceCenter.addObserver( + forName: NSWorkspace.willPowerOffNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.isTerminatingApp = true + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } + } + lifecycleSnapshotObservers.append(powerOffObserver) + + let sessionResignObserver = workspaceCenter.addObserver( + forName: NSWorkspace.sessionDidResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + if self.isTerminatingApp { + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } else { + _ = self.saveSessionSnapshot(includeScrollback: false) + } + } + } + lifecycleSnapshotObservers.append(sessionResignObserver) + + let didWakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.restartSocketListenerIfEnabled(source: "workspace.didWake") + } + } + lifecycleSnapshotObservers.append(didWakeObserver) + } + + private func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? { + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + guard mode != .off else { return nil } + return (mode: mode, path: SocketControlSettings.socketPath()) + } + + private func restartSocketListenerIfEnabled(source: String) { + guard let tabManager, + let config = socketListenerConfigurationIfEnabled() else { return } + sentryBreadcrumb("socket.listener.restart", category: "socket", data: [ + "mode": config.mode.rawValue, + "path": config.path, + "source": source + ]) + TerminalController.shared.stop() + TerminalController.shared.start(tabManager: tabManager, socketPath: config.path, accessMode: config.mode) + } + + private func startSocketListenerHealthMonitorIfNeeded() { + guard socketListenerHealthTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule( + deadline: .now() + Self.socketListenerHealthCheckInterval, + repeating: Self.socketListenerHealthCheckInterval + ) + timer.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + self?.restartSocketListenerIfNeededForHealthCheck(source: "health.timer") + } + } + timer.resume() + socketListenerHealthTimer = timer + } + + private func stopSocketListenerHealthMonitor() { + socketListenerHealthTimer?.cancel() + socketListenerHealthTimer = nil + socketListenerHealthCheckInFlight = false + } + + private func restartSocketListenerIfNeededForHealthCheck(source: String) { + guard !socketListenerHealthCheckInFlight, + let config = socketListenerConfigurationIfEnabled() else { return } + let expectedSocketPath = config.path + let terminalController = TerminalController.shared + socketListenerHealthCheckInFlight = true + Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in + let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath) + Task { @MainActor [weak self, health] in + guard let self else { return } + self.socketListenerHealthCheckInFlight = false + self.handleSocketListenerHealthCheckResult( + health, + source: source, + expectedSocketPath: expectedSocketPath + ) + } + } + } + + private func handleSocketListenerHealthCheckResult( + _ health: TerminalController.SocketListenerHealth, + source: String, + expectedSocketPath: String + ) { + guard let config = socketListenerConfigurationIfEnabled(), + config.path == expectedSocketPath else { return } + guard !health.isHealthy else { + lastSocketListenerUnhealthyCaptureAt = .distantPast + return + } + let failureSignals = health.failureSignals + var data: [String: Any] = [ + "source": source, + "path": config.path, + "isRunning": health.isRunning ? 1 : 0, + "acceptLoopAlive": health.acceptLoopAlive ? 1 : 0, + "socketPathMatches": health.socketPathMatches ? 1 : 0, + "socketPathExists": health.socketPathExists ? 1 : 0, + "socketProbePerformed": health.socketProbePerformed ? 1 : 0, + "failureSignals": failureSignals + ] + if let socketConnectable = health.socketConnectable { + data["socketConnectable"] = socketConnectable ? 1 : 0 + } + if let socketConnectErrno = health.socketConnectErrno { + data["socketConnectErrno"] = Int(socketConnectErrno) + } + sentryBreadcrumb("socket.listener.unhealthy", category: "socket", data: data) + let now = Date() + if now.timeIntervalSince(lastSocketListenerUnhealthyCaptureAt) >= Self.socketListenerUnhealthyCaptureCooldown { + lastSocketListenerUnhealthyCaptureAt = now + sentryCaptureWarning( + "socket.listener.unhealthy", + category: "socket", + data: data, + contextKey: "socket_listener_health" + ) + } + restartSocketListenerIfEnabled(source: source) + } + + private func disableSuddenTerminationIfNeeded() { + guard !didDisableSuddenTermination else { return } + ProcessInfo.processInfo.disableSuddenTermination() + didDisableSuddenTermination = true + } + + private func enableSuddenTerminationIfNeeded() { + guard didDisableSuddenTermination else { return } + ProcessInfo.processInfo.enableSuddenTermination() + didDisableSuddenTermination = false + } + + private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { + guard !includeScrollback else { return nil } + + var hasher = Hasher() + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + lhs.windowId.uuidString < rhs.windowId.uuidString + } + hasher.combine(contexts.count) + + for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { + hasher.combine(context.windowId) + hasher.combine(context.tabManager.sessionAutosaveFingerprint()) + hasher.combine(context.sidebarState.isVisible) + hasher.combine( + Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) + ) + + switch context.sidebarSelectionState.selection { + case .tabs: + hasher.combine(0) + case .notifications: + hasher.combine(1) + } + + if let window = context.window ?? windowForMainWindowId(context.windowId) { + Self.hashFrame(window.frame, into: &hasher) + } else { + hasher.combine(-1) + } + } + + return hasher.finalize() + } + + @discardableResult + private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + if Self.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, + includeScrollback: includeScrollback + ) { +#if DEBUG + dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") +#endif + return false + } + + let writeSynchronously = Self.shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: isTerminatingApp, + includeScrollback: includeScrollback + ) +#if DEBUG + let timingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "session.saveSnapshot", + startedAt: timingStart, + extra: "includeScrollback=\(includeScrollback ? 1 : 0) removeWhenEmpty=\(removeWhenEmpty ? 1 : 0) sync=\(writeSynchronously ? 1 : 0)" + ) + } +#endif + + guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { + persistSessionSnapshot( + nil, + removeWhenEmpty: removeWhenEmpty, + persistedGeometryData: nil, + synchronously: writeSynchronously + ) + return false + } + + let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in + Self.encodedPersistedWindowGeometryData( + frame: primaryWindow.frame, + display: primaryWindow.display + ) + } + +#if DEBUG + debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) +#endif + persistSessionSnapshot( + snapshot, + removeWhenEmpty: false, + persistedGeometryData: persistedGeometryData, + synchronously: writeSynchronously + ) + return true + } + + nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp + } + + nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: Bool + ) -> Bool { + !isTerminatingApp + } + + nonisolated static func shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: Bool, + includeScrollback: Bool + ) -> Bool { + isApplyingStartupSessionRestore && !includeScrollback + } + + nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp + } + + private func remainingSessionAutosaveTypingQuietPeriod( + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> TimeInterval? { + guard lastTypingActivityAt > 0 else { return nil } + let elapsed = nowUptime - lastTypingActivityAt + guard elapsed < Self.sessionAutosaveTypingQuietPeriod else { return nil } + return Self.sessionAutosaveTypingQuietPeriod - elapsed + } + + private func scheduleDeferredSessionAutosaveRetry(after delay: TimeInterval) { + guard delay.isFinite, delay > 0 else { return } + guard !sessionAutosaveDeferredRetryPending else { return } + sessionAutosaveDeferredRetryPending = true + sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.sessionAutosaveDeferredRetryPending = false + self.runSessionAutosaveTick(source: "typingQuietRetry") + } + } + } + + private func runSessionAutosaveTick(source: String) { + guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { return } + guard !sessionAutosaveTickInFlight else { return } + if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { +#if DEBUG + dlog( + "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + + "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" + ) +#endif + scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) + return + } + + sessionAutosaveTickInFlight = true +#if DEBUG + let timingStart = CmuxTypingTiming.start() + let phaseStart = ProcessInfo.processInfo.systemUptime + var fingerprintMs: Double = 0 + var saveMs: Double = 0 + defer { + sessionAutosaveTickInFlight = false + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "session.autosaveTick.phase", + totalMs: totalMs, + thresholdMs: 2.0, + parts: [ + ("fingerprintMs", fingerprintMs), + ("saveMs", saveMs), + ], + extra: "source=\(source)" + ) + CmuxTypingTiming.logDuration( + path: "session.autosaveTick", + startedAt: timingStart, + extra: "source=\(source)" + ) + } +#else + defer { sessionAutosaveTickInFlight = false } +#endif + + let now = Date() +#if DEBUG + let fingerprintStart = ProcessInfo.processInfo.systemUptime +#endif + let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false) +#if DEBUG + fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 +#endif + if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: isTerminatingApp, + includeScrollback: false, + previousFingerprint: lastSessionAutosaveFingerprint, + currentFingerprint: autosaveFingerprint, + lastPersistedAt: lastSessionAutosavePersistedAt, + now: now + ) { +#if DEBUG + dlog( + "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" + ) +#endif + return + } + +#if DEBUG + let saveStart = ProcessInfo.processInfo.systemUptime +#endif + _ = saveSessionSnapshot(includeScrollback: false) +#if DEBUG + saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 +#endif + updateSessionAutosaveSaveState( + includeScrollback: false, + persistedAt: now, + fingerprint: autosaveFingerprint + ) + } + + fileprivate func recordTypingActivity() { + lastTypingActivityAt = ProcessInfo.processInfo.systemUptime + } + + nonisolated static func shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: Bool, + includeScrollback: Bool + ) -> Bool { + isTerminatingApp && includeScrollback + } + + nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: Bool, + includeScrollback: Bool, + previousFingerprint: Int?, + currentFingerprint: Int?, + lastPersistedAt: Date, + now: Date, + maximumAutosaveSkippableInterval: TimeInterval = 60 + ) -> Bool { + guard !isTerminatingApp, + !includeScrollback, + let previousFingerprint, + let currentFingerprint, + previousFingerprint == currentFingerprint else { + return false + } + + return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval + } + + private func updateSessionAutosaveSaveState( + includeScrollback: Bool, + persistedAt: Date, + fingerprint: Int? + ) { + guard !isTerminatingApp, !includeScrollback else { return } + lastSessionAutosaveFingerprint = fingerprint + lastSessionAutosavePersistedAt = persistedAt + } + + private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { + let standardized = frame.standardized + let quantized = [ + standardized.origin.x, + standardized.origin.y, + standardized.size.width, + standardized.size.height, + ].map { Int(($0 * 2).rounded()) } + quantized.forEach { hasher.combine($0) } + } + + private func persistSessionSnapshot( + _ snapshot: AppSessionSnapshot?, + removeWhenEmpty: Bool, + persistedGeometryData: Data?, + synchronously: Bool + ) { + guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } + + let writeBlock = { + if let persistedGeometryData { + UserDefaults.standard.set( + persistedGeometryData, + forKey: Self.persistedWindowGeometryDefaultsKey + ) + } + if let snapshot { + _ = SessionPersistenceStore.save(snapshot) + } else if removeWhenEmpty { + SessionPersistenceStore.removeSnapshot() + } + } + + if synchronously { + writeBlock() + } else { + sessionPersistenceQueue.async(execute: writeBlock) + } + } + + private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) + let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) + let lhsIsKey = lhsWindow?.isKeyWindow ?? false + let rhsIsKey = rhsWindow?.isKeyWindow ?? false + if lhsIsKey != rhsIsKey { + return lhsIsKey && !rhsIsKey + } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + guard !contexts.isEmpty else { return nil } + + let windows: [SessionWindowSnapshot] = contexts + .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) + .map { context in + let window = context.window ?? windowForMainWindowId(context.windowId) + return SessionWindowSnapshot( + frame: window.map { SessionRectSnapshot($0.frame) }, + display: displaySnapshot(for: window), + tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), + sidebar: SessionSidebarSnapshot( + isVisible: context.sidebarState.isVisible, + selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), + width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) + ) + ) + } + + guard !windows.isEmpty else { return nil } + return AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: windows + ) + } + +#if DEBUG + private func debugLogSessionSaveSnapshot( + _ snapshot: AppSessionSnapshot, + includeScrollback: Bool + ) { + dlog( + "session.save includeScrollback=\(includeScrollback ? 1 : 0) " + + "windows=\(snapshot.windows.count)" + ) + for (index, windowSnapshot) in snapshot.windows.enumerated() { + let workspaceCount = windowSnapshot.tabManager.workspaces.count + let selectedWorkspace = windowSnapshot.tabManager.selectedWorkspaceIndex.map(String.init) ?? "nil" + dlog( + "session.save.window idx=\(index) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + + "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" + ) + } + } + + private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " + + "w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))" + } + + private func debugNSRectDescription(_ rect: NSRect?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(Double(rect.origin.x))) " + + "y=\(debugSessionNumber(Double(rect.origin.y))) " + + "w=\(debugSessionNumber(Double(rect.size.width))) " + + "h=\(debugSessionNumber(Double(rect.size.height)))" + } + + private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String { + guard let display else { return "nil" } + let displayIdText = display.displayID.map(String.init) ?? "nil" + return "id=\(displayIdText) " + + "frame={\(debugSessionRectDescription(display.frame))} " + + "visible={\(debugSessionRectDescription(display.visibleFrame))}" + } + + private func debugSessionNumber(_ value: Double) -> String { + String(format: "%.1f", value) + } +#endif + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -478,9 +3417,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarState: SidebarState, sidebarSelectionState: SidebarSelectionState ) { + tabManager.window = window + let key = ObjectIdentifier(window) + #if DEBUG + let priorManagerToken = debugManagerToken(self.tabManager) + #endif if let existing = mainWindowContexts[key] { existing.window = window + } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + existing.window = window + reindexMainWindowContextIfNeeded(existing, for: window) } else { mainWindowContexts[key] = MainWindowContext( windowId: windowId, @@ -498,10 +3445,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.unregisterMainWindow(closing) } } + commandPaletteVisibilityByWindowId[windowId] = false + commandPaletteSelectionByWindowId[windowId] = 0 + commandPaletteSnapshotByWindowId[windowId] = .empty +#if DEBUG + dlog( + "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" + ) +#endif if window.isKeyWindow { setActiveMainWindow(window) } + + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + if !isTerminatingApp { + _ = saveSessionSnapshot(includeScrollback: false) + } } struct MainWindowSummary { @@ -512,6 +3472,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let selectedWorkspaceId: UUID? } + struct WindowMoveTarget: Identifiable { + let windowId: UUID + let label: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: UUID { windowId } + } + + struct WorkspaceMoveTarget: Identifiable { + let windowId: UUID + let workspaceId: UUID + let windowLabel: String + let workspaceTitle: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } + var label: String { + isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" + } + } + func listMainWindowSummaries() -> [MainWindowSummary] { let contexts = Array(mainWindowContexts.values) return contexts.map { ctx in @@ -526,6 +3509,406 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + func windowMoveTargets(referenceWindowId: UUID?) -> [WindowMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) + return orderedSummaries.compactMap { summary in + guard let manager = tabManagerFor(windowId: summary.windowId) else { return nil } + let label = labels[summary.windowId] ?? "Window" + return WindowMoveTarget( + windowId: summary.windowId, + label: label, + tabManager: manager, + isCurrentWindow: summary.windowId == referenceWindowId + ) + } + } + + func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) + + var targets: [WorkspaceMoveTarget] = [] + targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in + partial + summary.workspaceCount + }) + + for summary in orderedSummaries { + guard let manager = tabManagerFor(windowId: summary.windowId) else { continue } + let windowLabel = labels[summary.windowId] ?? "Window" + let isCurrentWindow = summary.windowId == referenceWindowId + for workspace in manager.tabs { + if workspace.id == excludingWorkspaceId { + continue + } + targets.append( + WorkspaceMoveTarget( + windowId: summary.windowId, + workspaceId: workspace.id, + windowLabel: windowLabel, + workspaceTitle: workspaceDisplayName(workspace), + tabManager: manager, + isCurrentWindow: isCurrentWindow + ) + ) + } + } + + return targets + } + + @discardableResult + func moveWorkspaceToWindow(workspaceId: UUID, windowId: UUID, focus: Bool = true) -> Bool { + guard let sourceManager = tabManagerFor(tabId: workspaceId), + let destinationManager = tabManagerFor(windowId: windowId) else { + return false + } + + if sourceManager === destinationManager { + if focus { + destinationManager.focusTab(workspaceId, suppressFlash: true) + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) + } + return true + } + + guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } + destinationManager.attachWorkspace(workspace, select: focus) + + if focus { + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) + } + return true + } + + @discardableResult + func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { + let windowId = createMainWindow() + guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } + let bootstrapWorkspaceId = destinationManager.tabs.first?.id + + guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { + _ = closeMainWindow(windowId: windowId) + return nil + } + + // Remove the bootstrap workspace from the new window once the moved workspace arrives. + if let bootstrapWorkspaceId, + bootstrapWorkspaceId != workspaceId, + let bootstrapWorkspace = destinationManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), + destinationManager.tabs.count > 1 { + destinationManager.closeWorkspace(bootstrapWorkspace) + } + return windowId + } + + func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + let bonsplitTabId = TabID(uuid: tabId) + for context in mainWindowContexts.values { + for workspace in context.tabManager.tabs { + if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { + return (context.windowId, workspace.id, panelId, context.tabManager) + } + } + } + return nil + } + + @discardableResult + func moveSurface( + panelId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true + ) -> Bool { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + let splitLabel = splitTarget.map { split in + "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" + } ?? "none" + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)" + ) +#endif + guard let source = locateSurface(surfaceId: panelId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } +#if DEBUG + dlog( + "surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " + + "sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)" + ) +#endif + + let resolvedTargetPane = targetPane.flatMap { pane in + destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first + + guard let resolvedTargetPane else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + + "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } + + if destinationWorkspace.id == sourceWorkspace.id { + if let splitTarget { + guard let sourceTabId = sourceWorkspace.surfaceIdFromPanelId(panelId), + sourceWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: sourceTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } + if focus { + source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) + } +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return true + } + + let moved = sourceWorkspace.moveSurface( + panelId: panelId, + toPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved + } + + let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime +#endif + + guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + let detachMs = elapsedMs(since: detachStart) + let attachStart = ProcessInfo.processInfo.systemUptime +#endif + guard destinationWorkspace.attachDetachedSurface( + detached, + inPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) != nil else { + rollbackDetachedSurface( + detached, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + + "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + let attachMs = elapsedMs(since: attachStart) + var splitMs = "0.00" +#endif + + if let splitTarget { +#if DEBUG + let splitStart = ProcessInfo.processInfo.systemUptime +#endif + guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), + destinationWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: movedTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { + if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { + rollbackDetachedSurface( + detachedFromDestination, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) + } +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + + "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + splitMs = elapsedMs(since: splitStart) +#endif + } + +#if DEBUG + let cleanupStart = ProcessInfo.processInfo.systemUptime +#endif + cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: sourceWorkspace, + sourceManager: source.tabManager, + sourceWindowId: source.windowId + ) +#if DEBUG + let cleanupMs = elapsedMs(since: cleanupStart) + let focusStart = ProcessInfo.processInfo.systemUptime +#endif + + if focus { + let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil + if let destinationWindowId { + _ = focusMainWindow(windowId: destinationWindowId) + } + destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) + if let destinationWindowId { + reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: destinationWindowId, + sourceWindowId: source.windowId, + destinationWorkspaceId: targetWorkspaceId, + destinationPanelId: panelId, + destinationManager: destinationManager + ) + } + } +#if DEBUG + let focusMs = elapsedMs(since: focusStart) + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " + + "sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + + "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + + return true + } + + @discardableResult + func moveBonsplitTab( + tabId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true + ) -> Bool { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" + ) +#endif + guard let located = locateBonsplitSurface(tabId: tabId) else { +#if DEBUG + dlog( + "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + + "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + dlog( + "surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))" + ) +#endif + let moved = moveSurface( + panelId: located.panelId, + toWorkspace: targetWorkspaceId, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: focus, + focusWindow: focusWindow + ) +#if DEBUG + dlog( + "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved + } + func tabManagerFor(windowId: UUID) -> TabManager? { mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager } @@ -534,6 +3917,403 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId } + func mainWindow(for windowId: UUID) -> NSWindow? { + windowForMainWindowId(windowId) + } + + func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? { + for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) { + if let window = context.window ?? windowForMainWindowId(context.windowId) { + return window + } + } + return nil + } + + func scriptableMainWindows() -> [ScriptableMainWindowState] { + var results: [ScriptableMainWindowState] = [] + var seen: Set<UUID> = [] + + for window in NSApp.orderedWindows { + guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue } + guard seen.insert(context.windowId).inserted else { continue } + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + ) + } + + let remaining = mainWindowContexts.values + .sorted { $0.windowId.uuidString < $1.windowId.uuidString } + .filter { seen.insert($0.windowId).inserted } + + for context in remaining { + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + ) + } + + return results + } + + func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else { + return nil + } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + } + + func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? { + guard let context = contextContainingTabId(tabId) else { return nil } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + } + + @discardableResult + func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { + guard let state = scriptableMainWindow(windowId: windowId), + let window = state.window else { + return false + } + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } + return true + } + + @discardableResult + func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? { + guard let state = scriptableMainWindow(windowId: windowId) else { return nil } + if shouldBringToFront, let window = state.window { + setActiveMainWindow(window) + bringToFront(window) + } + let workspace = state.tabManager.addWorkspace( + workingDirectory: workingDirectory, + select: shouldBringToFront + ) + return workspace.id + } + + private func markCommandPaletteOpenRequested(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPalettePendingOpenByWindowId[windowId] = true + commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + } + + private func postCommandPaletteRequest( + name: Notification.Name, + preferredWindow: NSWindow?, + source: String, + markPending: Bool + ) { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + if markPending { + markCommandPaletteOpenRequested(for: targetWindow) + } + NotificationCenter.default.post(name: name, object: targetWindow) +#if DEBUG + dlog( + "shortcut.palette.request source=\(source) " + + "target={\(debugWindowToken(targetWindow))} " + + "pendingMarked=\(markPending ? 1 : 0)" + ) +#endif + } + + func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { + postCommandPaletteRequest( + name: .commandPaletteRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } + + func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { + postCommandPaletteRequest( + name: .commandPaletteSwitcherRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } + + func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { + postCommandPaletteRequest( + name: .commandPaletteRenameTabRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } + + func requestCommandPaletteRenameWorkspace( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteRenameWorkspace" + ) { + postCommandPaletteRequest( + name: .commandPaletteRenameWorkspaceRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } + + private func clearCommandPalettePendingOpen(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + } + + private func pruneExpiredCommandPalettePendingOpenStates( + now: TimeInterval = ProcessInfo.processInfo.systemUptime + ) { + for windowId in Array(commandPalettePendingOpenByWindowId.keys) { + guard commandPalettePendingOpenByWindowId[windowId] == true else { continue } + guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) +#if DEBUG + dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") +#endif + continue + } + let age = now - requestedAt + guard age > Self.commandPalettePendingOpenMaxAge else { continue } + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) +#if DEBUG + dlog( + "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + + "reason=stale ageMs=\(Int(age * 1000))" + ) +#endif + } + } + + private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + pruneExpiredCommandPalettePendingOpenStates() + return commandPalettePendingOpenByWindowId[windowId] == true + } + + private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPaletteEscapeSuppressionByWindowId.insert(windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + } + + private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPaletteEscapeSuppressionByWindowId.remove(windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) + } + + private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { + guard let window, + let windowId = mainWindowId(for: window), + commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { + return false + } + if event.isARepeat { + return true + } + let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 + if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { + return true + } + // Fallback cleanup when keyUp is lost for any reason. + endCommandPaletteEscapeSuppression(for: window) + return false + } + + private func recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { + guard let window, + let windowId = mainWindowId(for: window) else { + return nil + } + let now = ProcessInfo.processInfo.systemUptime + pruneExpiredCommandPalettePendingOpenStates(now: now) + guard commandPalettePendingOpenByWindowId[windowId] == true else { + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + return nil + } + guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + return nil + } + let age = now - startedAt + if age <= Self.commandPaletteRequestGraceInterval { + return age + } + return nil + } + + private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { + commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + + @discardableResult + private func clearEscapeSuppressionForKeyUp(event: NSEvent, consumeIfSuppressed: Bool = false) -> Bool { + guard event.type == .keyUp, event.keyCode == 53 else { return false } + let suppressionWindow = escapeSuppressionWindow(for: event) + let didConsume = consumeIfSuppressed && shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) + if let window = suppressionWindow { + endCommandPaletteEscapeSuppression(for: window) +#if DEBUG + dlog( + "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + + "keyUpConsumed=\(didConsume ? 1 : 0)" + ) +#endif + return didConsume + } + commandPaletteEscapeSuppressionByWindowId.removeAll() + commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() +#if DEBUG + dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") +#endif + return didConsume + } + + func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + let wasVisible = commandPaletteVisibilityByWindowId[windowId] ?? false + commandPaletteVisibilityByWindowId[windowId] = visible + // Opening (false -> true) always resolves pending-open. + // Closing (true -> false) also clears stale pending state. + // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. + if visible || wasVisible { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + } +#if DEBUG + if !visible, + !wasVisible, + commandPalettePendingOpenByWindowId[windowId] == true { + dlog( + "palette.visibility.retainPending " + + "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" + ) + } +#endif + } + + func isCommandPaletteVisible(windowId: UUID) -> Bool { + commandPaletteVisibilityByWindowId[windowId] ?? false + } + + func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSelectionByWindowId[windowId] = max(0, index) + } + + func commandPaletteSelectionIndex(windowId: UUID) -> Int { + commandPaletteSelectionByWindowId[windowId] ?? 0 + } + + func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSnapshotByWindowId[windowId] = snapshot + } + + func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { + commandPaletteSnapshotByWindowId[windowId] ?? .empty + } + + func isCommandPaletteVisible(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + return commandPaletteVisibilityByWindowId[windowId] ?? false + } + + func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: NSWindow, + responder: NSResponder? + ) -> Bool { + guard isCommandPaletteVisible(for: window) else { return false } + guard let responder else { return false } + guard !isCommandPaletteResponder(responder) else { return false } + return isFocusStealingResponderWhileCommandPaletteVisible(responder) + } + + private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { + if let textView = responder as? NSTextView, textView.isFieldEditor { + if let delegateView = textView.delegate as? NSView { + return isInsideCommandPaletteOverlay(delegateView) + } + // SwiftUI can attach a non-view delegate to TextField editors. + // When command palette is visible, its search/rename editor is the + // only expected field editor inside the main window. + return true + } + if let view = responder as? NSView { + return isInsideCommandPaletteOverlay(view) + } + return false + } + + private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { + if responder is GhosttyNSView || responder is WKWebView { + return true + } + + if let textView = responder as? NSTextView, + !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView { + return isTerminalOrBrowserView(delegateView) + } + + if let view = responder as? NSView { + return isTerminalOrBrowserView(view) + } + + return false + } + + private func isTerminalOrBrowserView(_ view: NSView) -> Bool { + if view is GhosttyNSView || view is WKWebView { + return true + } + var current: NSView? = view.superview + while let candidate = current { + if candidate is GhosttyNSView || candidate is WKWebView { + return true + } + current = candidate.superview + } + return false + } + + private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return true + } + current = candidate.superview + } + return false + } + func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { for ctx in mainWindowContexts.values { for ws in ctx.tabManager.tabs { @@ -545,6 +4325,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } + /// Resolve the workspace that currently owns a panel/surface ID. + /// Prefer the provided workspace when available, then fall back to global lookup. + func workspaceContainingPanel( + panelId: UUID, + preferredWorkspaceId: UUID? = nil + ) -> (workspace: Workspace, tabManager: TabManager)? { + if let preferredWorkspaceId, + let manager = tabManagerFor(tabId: preferredWorkspaceId), + let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), + workspace.panels[panelId] != nil { + return (workspace, manager) + } + + if let located = locateSurface(surfaceId: panelId), + let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), + workspace.panels[panelId] != nil { + return (workspace, located.tabManager) + } + + if let preferredWorkspaceId, + let manager = tabManagerFor(tabId: preferredWorkspaceId) ?? tabManager, + let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), + workspace.panels[panelId] != nil { + return (workspace, manager) + } + + if let manager = tabManager, + let workspace = manager.tabs.first(where: { $0.panels[panelId] != nil }) { + return (workspace, manager) + } + + return nil + } + func locateGhosttySurface(_ surface: ghostty_surface_t?) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { guard let surface else { return nil } for ctx in mainWindowContexts.values { @@ -560,6 +4374,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } + func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { + var refreshedCount = 0 + forEachTerminalPanel { terminalPanel in + terminalPanel.hostedView.reconcileGeometryNow() + terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") + refreshedCount += 1 + } +#if DEBUG + dlog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") +#endif + } + + private func forEachTerminalPanel(_ body: (TerminalPanel) -> Void) { + var seenManagers: Set<ObjectIdentifier> = [] + + func visitManager(_ manager: TabManager?) { + guard let manager else { return } + let managerId = ObjectIdentifier(manager) + guard seenManagers.insert(managerId).inserted else { return } + for workspace in manager.tabs { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + body(terminalPanel) + } + } + } + + visitManager(tabManager) + for context in mainWindowContexts.values { + visitManager(context.tabManager) + } + } + func focusMainWindow(windowId: UUID) -> Bool { guard let window = windowForMainWindowId(windowId) else { return false } if TerminalController.shouldSuppressSocketCommandActivation() { @@ -582,6 +4429,144 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + private func confirmCloseMainWindow(_ window: NSWindow) -> Bool { +#if DEBUG + if let debugCloseMainWindowConfirmationHandler { + return debugCloseMainWindowConfirmationHandler(window) + } +#endif + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + alert.informativeText = String( + localized: "dialog.closeWindow.message", + defaultValue: "This will close the current window and all of its workspaces." + ) + alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + if let closeButton = alert.buttons.first { + alertWindow.defaultButtonCell = closeButton.cell as? NSButtonCell + alertWindow.initialFirstResponder = closeButton + DispatchQueue.main.async { + _ = alertWindow.makeFirstResponder(closeButton) + } + } + + return alert.runModal() == .alertFirstButtonReturn + } + + @discardableResult + func closeWindowWithConfirmation(_ window: NSWindow) -> Bool { + guard isMainTerminalWindow(window) else { + window.performClose(nil) + return true + } + guard confirmCloseMainWindow(window) else { return true } + window.performClose(nil) + return true + } + + private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { + let summaries = listMainWindowSummaries() + return summaries.sorted { lhs, rhs in + let lhsIsReference = lhs.windowId == referenceWindowId + let rhsIsReference = rhs.windowId == referenceWindowId + if lhsIsReference != rhsIsReference { return lhsIsReference } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + } + + private func windowLabelsById(orderedSummaries: [MainWindowSummary], referenceWindowId: UUID?) -> [UUID: String] { + var labels: [UUID: String] = [:] + for (index, summary) in orderedSummaries.enumerated() { + if summary.windowId == referenceWindowId { + labels[summary.windowId] = String(localized: "menu.currentWindow", defaultValue: "Current Window") + } else { + let number = index + 1 + labels[summary.windowId] = String(localized: "menu.windowNumber", defaultValue: "Window \(number)") + } + } + return labels + } + + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed + } + + private func rollbackDetachedSurface( + _ detached: Workspace.DetachedSurfaceTransfer, + to workspace: Workspace, + sourcePane: PaneID?, + sourceIndex: Int?, + focus: Bool + ) { + let rollbackPane = sourcePane.flatMap { pane in + workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first + guard let rollbackPane else { return } + _ = workspace.attachDetachedSurface( + detached, + inPane: rollbackPane, + atIndex: sourceIndex, + focus: focus + ) + } + + private func cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: Workspace, + sourceManager: TabManager, + sourceWindowId: UUID + ) { + guard sourceWorkspace.panels.isEmpty else { return } + guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } + + if sourceManager.tabs.count > 1 { + sourceManager.closeWorkspace(sourceWorkspace) + } else { + _ = closeMainWindow(windowId: sourceWindowId) + } + } + + private func reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: UUID, + sourceWindowId: UUID, + destinationWorkspaceId: UUID, + destinationPanelId: UUID, + destinationManager: TabManager + ) { + let reassert: () -> Void = { [weak self, weak destinationManager] in + guard let self, let destinationManager else { return } + guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), + workspace.panels[destinationPanelId] != nil else { + return + } + guard let destinationWindow = self.mainWindow(for: destinationWindowId) else { return } + guard let keyWindow = NSApp.keyWindow, + let keyWindowId = self.mainWindowId(for: keyWindow), + keyWindowId == sourceWindowId, + keyWindow !== destinationWindow else { + return + } + + self.bringToFront(destinationWindow) + destinationManager.focusTab( + destinationWorkspaceId, + surfaceId: destinationPanelId, + suppressFlash: true + ) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) + } + private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), let window = ctx.window { @@ -591,6 +4576,381 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) } + private func mainWindowId(from window: NSWindow) -> UUID? { + guard let raw = window.identifier?.rawValue else { return nil } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix) else { return nil } + let suffix = String(raw.dropFirst(prefix.count)) + return UUID(uuidString: suffix) + } + + private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { + let desiredKey = ObjectIdentifier(window) + if mainWindowContexts[desiredKey] === context { + context.window = window + return + } + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + + if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { + context.window = window + return + } + + mainWindowContexts[desiredKey] = context + context.window = window + } + + private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { + guard isMainTerminalWindow(window) else { return nil } + + if let context = mainWindowContexts[ObjectIdentifier(window)] { + context.window = window + return context + } + + if let windowId = mainWindowId(from: window), + let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context + } + + let windowNumber = window.windowNumber + if windowNumber >= 0, + let context = mainWindowContexts.values.first(where: { candidate in + let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) + return candidateWindow?.windowNumber == windowNumber + }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context + } + + return nil + } + + private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { + guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } + let removedKeys = mainWindowContexts.compactMap { key, value in + value === removed ? key : nil + } + for key in removedKeys { + mainWindowContexts.removeValue(forKey: key) + } + return removed + } + + private func mainWindowId(for window: NSWindow) -> UUID? { + if let context = mainWindowContexts[ObjectIdentifier(window)] { + return context.windowId + } + guard let rawIdentifier = window.identifier?.rawValue, + rawIdentifier.hasPrefix("cmux.main.") else { return nil } + let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) + return UUID(uuidString: idPart) + } + + private func commandPaletteOverlayContainer(in window: NSWindow) -> NSView? { + guard let searchRoot = window.contentView?.superview ?? window.contentView else { return nil } + var stack: [NSView] = [searchRoot] + while let candidate = stack.popLast() { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return candidate + } + stack.append(contentsOf: candidate.subviews) + } + return nil + } + + private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { + guard let container = commandPaletteOverlayContainer(in: window) else { return false } + return !container.isHidden && container.alphaValue > 0.001 + } + + private func isCommandPaletteResponderActive(in window: NSWindow) -> Bool { + guard let responder = window.firstResponder else { return false } + if let textView = responder as? NSTextView, + textView.isFieldEditor, + !(textView.delegate is NSView) { + // Field-editor delegates can be non-view responders. Confirm the overlay is + // mounted and visible to avoid treating unrelated editors as palette input. + return isCommandPaletteOverlayPresented(in: window) + } + return isCommandPaletteResponder(responder) + } + + private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { + if let textView = window.firstResponder as? NSTextView, + isCommandPaletteResponder(textView), + textView.hasMarkedText() { + return textView + } + + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView, + isCommandPaletteResponder(editor), + editor.hasMarkedText() { + return editor + } + + return nil + } + + private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { + isCommandPaletteVisible(for: window) + || isCommandPalettePendingOpen(for: window) + || isCommandPaletteOverlayPresented(in: window) + || isCommandPaletteResponderActive(in: window) + } + + private func activeCommandPaletteWindow() -> NSWindow? { + pruneExpiredCommandPalettePendingOpenStates() + if let keyWindow = NSApp.keyWindow, + isMainTerminalWindow(keyWindow), + isCommandPaletteEffectivelyVisible(in: keyWindow) { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, + isMainTerminalWindow(mainWindow), + isCommandPaletteEffectivelyVisible(in: mainWindow) { + return mainWindow + } + if let orderedWindow = NSApp.orderedWindows.first(where: { window in + isMainTerminalWindow(window) && isCommandPaletteEffectivelyVisible(in: window) + }) { + return orderedWindow + } + if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(visibleWindowId) + } + if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(pendingWindowId) + } + return nil + } + + private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let scopedWindow = mainWindowForShortcutEvent(event) { + return scopedWindow + } + return activeCommandPaletteWindow() + } + + private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { + guard let window, isMainTerminalWindow(window) else { return nil } + return mainWindowContexts[ObjectIdentifier(window)] + } + +#if DEBUG + private func debugManagerToken(_ manager: TabManager?) -> String { + guard let manager else { return "nil" } + return String(describing: Unmanaged.passUnretained(manager).toOpaque()) + } + + private func debugWindowToken(_ window: NSWindow?) -> String { + guard let window else { return "nil" } + let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none" + let ident = window.identifier?.rawValue ?? "nil" + let shortIdent: String + if ident.count > 120 { + shortIdent = String(ident.prefix(120)) + "..." + } else { + shortIdent = ident + } + return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)" + } + + private func debugContextToken(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0 + return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)" + } + + private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String { + let activeManager = tabManager + let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil" + let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + + let contexts = mainWindowContexts.values + .map { context in + let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-" + let window = context.window ?? windowForMainWindowId(context.windowId) + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}" + } + .sorted() + .joined(separator: ",") + + let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil" + let eventWindow = event?.window + return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" + } +#endif + + private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let window = event.window, isMainTerminalWindow(window) { + return window + } + let eventWindowNumber = event.windowNumber + if eventWindowNumber > 0, + let numberedWindow = NSApp.window(withWindowNumber: eventWindowNumber), + isMainTerminalWindow(numberedWindow) { + return numberedWindow + } + if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { + return mainWindow + } + return nil + } + + /// Re-sync app-level active window pointers from the currently focused main terminal window. + /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. + @discardableResult + func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? { + let (context, source): (MainWindowContext?, String) = { + if let preferredWindow, + let context = contextForMainWindow(preferredWindow) { + return (context, "preferredWindow") + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return (context, "keyWindow") + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return (context, "mainWindow") + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return (activeContext, "activeManager") + } + return (mainWindowContexts.values.first, "firstContextFallback") + }() + +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) + dlog( + "shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) +#endif + guard let context else { return tabManager } + let alreadyActive = + tabManager === context.tabManager + && sidebarState === context.sidebarState + && sidebarSelectionState === context.sidebarSelectionState + if alreadyActive { +#if DEBUG + dlog( + "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} nochange=1 \(debugShortcutRouteSnapshot())" + ) +#endif + return context.tabManager + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) + } +#if DEBUG + dlog( + "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) +#endif + return context.tabManager + } + + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { + if let context = contextForMainWindow(event.window) { + return context + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return context + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return context + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return activeContext + } + return mainWindowContexts.values.first + } + + private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { + let preferredWindow = mainWindowForShortcutEvent(event) +#if DEBUG + dlog( + "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) +#if DEBUG + dlog( + "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + } + + @discardableResult + func toggleSidebarInActiveMainWindow() -> Bool { + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + if let window = activeContext.window ?? windowForMainWindowId(activeContext.windowId) { + setActiveMainWindow(window) + } + activeContext.sidebarState.toggle() + return true + } + if let keyContext = contextForMainWindow(NSApp.keyWindow) { + if let window = keyContext.window ?? windowForMainWindowId(keyContext.windowId) { + setActiveMainWindow(window) + } + keyContext.sidebarState.toggle() + return true + } + if let mainContext = contextForMainWindow(NSApp.mainWindow) { + if let window = mainContext.window ?? windowForMainWindowId(mainContext.windowId) { + setActiveMainWindow(window) + } + mainContext.sidebarState.toggle() + return true + } + if let fallbackContext = mainWindowContexts.values.first { + if let window = fallbackContext.window ?? windowForMainWindowId(fallbackContext.windowId) { + setActiveMainWindow(window) + } + fallbackContext.sidebarState.toggle() + return true + } + if let sidebarState { + sidebarState.toggle() + return true + } + return false + } + + func sidebarVisibility(windowId: UUID) -> Bool? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible + } + @objc func openNewMainWindow(_ sender: Any?) { _ = createMainWindow() } @@ -621,6 +4981,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent target: ServiceOpenTarget, error: AutoreleasingUnsafeMutablePointer<NSString> ) { + didHandleExplicitOpenIntentAtStartup = true + if !didAttemptStartupSessionRestore { + startupSessionSnapshot = nil + didAttemptStartupSessionRestore = true + } + let pathURLs = servicePathURLs(from: pasteboard) guard !pathURLs.isEmpty else { error.pointee = Self.serviceErrorNoPath @@ -672,26 +5038,253 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func openWorkspaceFromService(workingDirectory: String) { - if let context = preferredMainWindowContextForServiceWorkspace(), - let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) - _ = context.tabManager.addWorkspace(workingDirectory: workingDirectory) + if addWorkspaceInPreferredMainWindow( + workingDirectory: workingDirectory, + shouldBringToFront: true, + debugSource: "service.openTab" + ) != nil { return } _ = createMainWindow(initialWorkingDirectory: workingDirectory) } - private func preferredMainWindowContextForServiceWorkspace() -> MainWindowContext? { + @discardableResult + func addWorkspaceInPreferredMainWindow( + workingDirectory: String? = nil, + shouldBringToFront: Bool = false, + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> UUID? { + #if DEBUG + logWorkspaceCreationRouting( + phase: "request", + source: debugSource, + reason: "add_workspace", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + #if DEBUG + logWorkspaceCreationRouting( + phase: "no_context", + source: debugSource, + reason: "context_selection_failed", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + return nil + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } + } + + let workspace: Workspace + if let workingDirectory { + workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) + } else { + workspace = context.tabManager.addTab(select: true) + } + #if DEBUG + logWorkspaceCreationRouting( + phase: "created", + source: debugSource, + reason: "workspace_created", + event: event, + chosenContext: context, + workspaceId: workspace.id, + workingDirectory: workingDirectory + ) + #endif + return workspace.id + } + + private func preferredMainWindowContextForWorkspaceCreation( + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { + return context + } + + // If a keyboard event identifies a specific window but that context + // can't be resolved, do not fall back to another window. + if shortcutEventHasAddressableWindow(event) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) +#endif + return nil + } + if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let context = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let context = contextForMainTerminalWindow(keyWindow) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "key_window", + event: event, + chosenContext: context + ) + #endif return context } if let mainWindow = NSApp.mainWindow, - isMainTerminalWindow(mainWindow), - let context = mainWindowContexts[ObjectIdentifier(mainWindow)] { + let context = contextForMainTerminalWindow(mainWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "main_window", + event: event, + chosenContext: context + ) + #endif + return context + } + + for window in NSApp.orderedWindows where isMainTerminalWindow(window) { + if let context = contextForMainTerminalWindow(window) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "ordered_windows", + event: event, + chosenContext: context + ) + #endif + return context + } + } + + let fallback = mainWindowContexts.values.first + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "fallback_first_context", + event: event, + chosenContext: fallback + ) +#endif + return fallback + } + + private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { + guard let event else { return false } + // NSEvent.windowNumber can be 0 for responder-chain events that are not + // actually bound to an NSWindow (notably some WebKit key paths). + return event.window != nil || event.windowNumber > 0 + } + + private func mainWindowContext( + forShortcutEvent event: NSEvent?, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + guard let event else { return nil } + + if let eventWindow = event.window, + let context = contextForMainTerminalWindow(eventWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window", + event: event, + chosenContext: context + ) + #endif + return context + } + + if event.windowNumber > 0, + let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), + let context = contextForMainTerminalWindow(numberedWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number", + event: event, + chosenContext: context + ) + #endif + return context + } + + if event.windowNumber > 0, + let context = mainWindowContexts.values.first(where: { candidate in + let window = candidate.window ?? windowForMainWindowId(candidate.windowId) + return window?.windowNumber == event.windowNumber + }) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number_scan", + event: event, + chosenContext: context + ) + #endif + return context + } + + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_not_found", + event: event, + chosenContext: nil + ) + #endif + return nil + } + + private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { + return context + } + + if shortcutEventHasAddressableWindow(event) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: "shortcut.routing", + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) +#endif + return nil + } + + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { + return context + } + + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + return context + } + + if let activeManager = tabManager, + let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { return context } @@ -699,11 +5292,60 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @discardableResult - func createMainWindow(initialWorkingDirectory: String? = nil) -> UUID { + private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool { + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + FocusLogStore.shared.append( + "shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)" + ) +#endif + return false + } + + let alreadyActive = + tabManager === context.tabManager + && sidebarState === context.sidebarState + && sidebarSelectionState === context.sidebarSelectionState + if alreadyActive { return true } + + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) + } + +#if DEBUG + FocusLogStore.shared.append( + "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" + ) +#endif + return true + } + + @discardableResult + func createMainWindow( + initialWorkingDirectory: String? = nil, + sessionWindowSnapshot: SessionWindowSnapshot? = nil + ) -> UUID { let windowId = UUID() let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) - let sidebarState = SidebarState() - let sidebarSelectionState = SidebarSelectionState() + if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { + tabManager.restoreSessionSnapshot(tabManagerSnapshot) + } + + let sidebarWidth = sessionWindowSnapshot?.sidebar.width + .map(SessionPersistencePolicy.sanitizedSidebarWidth) + ?? SessionPersistencePolicy.defaultSidebarWidth + let sidebarState = SidebarState( + isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, + persistedWidth: CGFloat(sidebarWidth) + ) + let sidebarSelectionState = SidebarSelectionState( + selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs + ) let notificationStore = TerminalNotificationStore.shared let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) @@ -722,7 +5364,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = false - window.center() + window.isMovable = false + let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) + if let restoredFrame { + window.setFrame(restoredFrame, display: false) + } else { + window.center() + } window.contentView = NSHostingView(rootView: root) // Apply shared window styling. @@ -756,6 +5404,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setActiveMainWindow(window) NSApp.activate(ignoringOtherApps: true) } + if let restoredFrame { + window.setFrame(restoredFrame, display: true) +#if DEBUG + dlog( + "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) +#endif + } return windowId } @@ -764,6 +5421,119 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updateController.checkForUpdates() } + func openWelcomeWorkspace() { + guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else { + return + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + bringToFront(window) + } + let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false) + sendWelcomeCommandWhenReady(to: workspace) + } + + func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) { + sendTextWhenReady("cmux welcome\n", to: workspace) { + if markShownOnSend { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + } + } + } + + @objc func applyUpdateIfAvailable(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.installUpdate() + } + + @objc func attemptUpdate(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.attemptUpdate() + } + + func isCmuxCLIInstalledInPATH() -> Bool { + CmuxCLIPathInstaller().isInstalled() + } + + @objc func installCmuxCLIInPath(_ sender: Any?) { + let installer = CmuxCLIPathInstaller() + do { + let outcome = try installer.install() + var informativeText = String(localized: "cli.install.symlinkCreated", defaultValue: "Created symlink:\n\n\(outcome.destinationURL.path) -> \(outcome.sourceURL.path)") + if outcome.usedAdministratorPrivileges { + informativeText += "\n\n" + String(localized: "cli.install.adminRequired", defaultValue: "Administrator privileges were required to write to /usr/local/bin.") + } + presentCLIPathAlert( + title: String(localized: "cli.installed", defaultValue: "cmux CLI Installed"), + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: String(localized: "cli.installFailed", defaultValue: "Couldn't Install cmux CLI"), + informativeText: error.localizedDescription, + style: .warning + ) + } + } + + @objc func uninstallCmuxCLIInPath(_ sender: Any?) { + let installer = CmuxCLIPathInstaller() + do { + let outcome = try installer.uninstall() + let prefix = outcome.removedExistingEntry + ? String(localized: "cli.uninstall.removed", defaultValue: "Removed \(outcome.destinationURL.path).") + : String(localized: "cli.uninstall.notFound", defaultValue: "No cmux CLI symlink was found at \(outcome.destinationURL.path).") + var informativeText = prefix + if outcome.usedAdministratorPrivileges { + informativeText += "\n\n" + String(localized: "cli.uninstall.adminRequired", defaultValue: "Administrator privileges were required to modify /usr/local/bin.") + } + presentCLIPathAlert( + title: String(localized: "cli.uninstalled", defaultValue: "cmux CLI Uninstalled"), + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: String(localized: "cli.uninstallFailed", defaultValue: "Couldn't Uninstall cmux CLI"), + informativeText: error.localizedDescription, + style: .warning + ) + } + } + + private func presentCLIPathAlert( + title: String, + informativeText: String, + style: NSAlert.Style + ) { + let alert = NSAlert() + alert.alertStyle = style + alert.messageText = title + alert.informativeText = informativeText + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + _ = alert.runModal() + } + } + + @objc func restartSocketListener(_ sender: Any?) { + guard tabManager != nil else { + NSSound.beep() + return + } + + guard socketListenerConfigurationIfEnabled() != nil else { + TerminalController.shared.stop() + NSSound.beep() + return + } + restartSocketListenerIfEnabled(source: "menu.command") + } + private func setupMenuBarExtra() { let store = TerminalNotificationStore.shared menuBarExtraController = MenuBarExtraController( @@ -785,7 +5555,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self?.checkForUpdates(nil) }, onOpenPreferences: { [weak self] in - self?.openPreferencesWindow() + self?.openPreferencesWindow(debugSource: "menuBarExtra") }, onQuitApp: { NSApp.terminate(nil) @@ -793,9 +5563,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + @MainActor + static func presentPreferencesWindow( + navigationTarget: SettingsNavigationTarget? = nil, + showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in + SettingsWindowController.shared.show(navigationTarget: target) + }, + activateApplication: @MainActor () -> Void = { + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + ) { +#if DEBUG + dlog("settings.open.present path=customWindowDirect") +#endif + showFallbackSettingsWindow(navigationTarget) + activateApplication() + if let window = SettingsWindowController.shared.window { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + } + } +#if DEBUG + dlog("settings.open.present activate=1") +#endif + } + + @MainActor + func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { +#if DEBUG + dlog("settings.open.request source=\(debugSource)") +#endif + Self.presentPreferencesWindow(navigationTarget: navigationTarget) + } + @objc func openPreferencesWindow() { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - NSApp.activate(ignoringOtherApps: true) + openPreferencesWindow(debugSource: "appDelegate") } func refreshMenuBarExtraForDebug() { @@ -805,8 +5610,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func showNotificationsPopoverFromMenuBar() { let context: MainWindowContext? = { if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let keyContext = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let keyContext = contextForMainTerminalWindow(keyWindow) { return keyContext } if let first = mainWindowContexts.values.first { @@ -879,7 +5683,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent pasteboard.setString(payload, forType: .string) } + private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0, beforeSend: (() -> Void)? = nil) { + let maxAttempts = 60 + if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { + beforeSend?() + terminalPanel.sendText(text) + return + } + guard attempt < maxAttempts else { + NSLog("Command send: surface not ready after \(maxAttempts) attempts") + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1, beforeSend: beforeSend) + } + } + #if DEBUG + private let debugColorWorkspaceTitlePrefix = "Debug Color - " + private let debugPerfWorkspaceTitlePrefix = "Debug Perf - " + private var debugStressWorkspaceCreationInProgress = false + private var debugStressLagProbeEnabled = false + private let debugStressWorkspaceCount = 20 + private let debugStressPaneCount = 4 + private let debugStressTabsPerPane = 4 + private let debugStressYieldInterval = 4 + private let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 + private let debugStressSurfaceLoadPollNanoseconds: UInt64 = 25_000_000 + @objc func openDebugScrollbackTab(_ sender: Any?) { guard let tabManager else { return } let tab = tabManager.addTab() @@ -903,19 +5734,472 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sendTextWhenReady(payload, to: tab) } - private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) { - let maxAttempts = 60 - if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { - terminalPanel.sendText(text) + @objc func openDebugColorComparisonWorkspaces(_ sender: Any?) { + guard let tabManager else { return } + + let palette = WorkspaceTabColorSettings.palette() + guard !palette.isEmpty else { return } + + var existingByTitle: [String: Workspace] = [:] + for tab in tabManager.tabs { + guard let title = tab.customTitle, + title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue } + existingByTitle[title] = tab + } + + for entry in palette { + let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" + let targetTab: Workspace + if let existing = existingByTitle[title] { + targetTab = existing + } else { + targetTab = tabManager.addTab() + } + tabManager.setCustomTitle(tabId: targetTab.id, title: title) + tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) + } + } + + @objc func openDebugStressWorkspacesWithLoadedSurfaces(_ sender: Any?) { + guard !debugStressWorkspaceCreationInProgress else { return } + guard let tabManager else { return } + + debugStressLagProbeEnabled = true + debugStressWorkspaceCreationInProgress = true + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.debugStressWorkspaceCreationInProgress = false } + + let totalStart = ProcessInfo.processInfo.systemUptime + let originalSelectedWorkspaceId = tabManager.selectedTabId + var created: [Workspace] = [] + created.reserveCapacity(self.debugStressWorkspaceCount) + var layoutFailures = 0 + var cumulativeWorkspaceMs: Double = 0 + var slowWorkspaceCount = 0 + var worstWorkspaceMs: Double = 0 + + dlog( + "stress.setup.start workspaces=\(self.debugStressWorkspaceCount) panes=\(self.debugStressPaneCount) " + + "tabsPerPane=\(self.debugStressTabsPerPane) lagProbe=1" + ) + + for index in 0..<self.debugStressWorkspaceCount { + let workspaceStart = ProcessInfo.processInfo.systemUptime + let workspace = tabManager.addWorkspace(select: false, placementOverride: .end) + created.append(workspace) + tabManager.setCustomTitle( + tabId: workspace.id, + title: "\(self.debugPerfWorkspaceTitlePrefix)\(index + 1)" + ) + + if !(await self.configureDebugStressWorkspaceLayout( + workspace, + paneCount: self.debugStressPaneCount, + tabsPerPane: self.debugStressTabsPerPane + )) { + layoutFailures += 1 + } + + let workspaceMs = (ProcessInfo.processInfo.systemUptime - workspaceStart) * 1000.0 + cumulativeWorkspaceMs += workspaceMs + worstWorkspaceMs = max(worstWorkspaceMs, workspaceMs) + if workspaceMs >= 35 { + slowWorkspaceCount += 1 + } + + if workspaceMs >= 35 || ((index + 1) % 5 == 0) { + let pending = self.pendingDebugTerminalSurfaceCount(in: created) + dlog( + "stress.setup.workspace idx=\(index + 1)/\(self.debugStressWorkspaceCount) " + + "ms=\(String(format: "%.2f", workspaceMs)) failures=\(layoutFailures) pending=\(pending)" + ) + } + + if ((index + 1) % self.debugStressYieldInterval) == 0 { + await Task.yield() + } + } + + let creationElapsedMs = (ProcessInfo.processInfo.systemUptime - totalStart) * 1000.0 + let loadStats = await self.loadAllDebugStressWorkspacesForTerminalSurfaceReadiness( + created, + tabManager: tabManager + ) + let totalElapsedMs = (ProcessInfo.processInfo.systemUptime - totalStart) * 1000.0 + let avgWorkspaceMs = created.isEmpty ? 0 : (cumulativeWorkspaceMs / Double(created.count)) + let expectedSurfaceCount = self.debugStressWorkspaceCount + * self.debugStressPaneCount + * self.debugStressTabsPerPane + if let originalSelectedWorkspaceId, + tabManager.tabs.contains(where: { $0.id == originalSelectedWorkspaceId }) { + tabManager.selectedTabId = originalSelectedWorkspaceId + } + + dlog( + "stress.setup.done createMs=\(String(format: "%.2f", creationElapsedMs)) " + + "loadMs=\(String(format: "%.2f", loadStats.elapsedMs)) loadedPanels=\(loadStats.loadedPanels) " + + "loadFailures=\(loadStats.failedPanels) totalMs=\(String(format: "%.2f", totalElapsedMs)) " + + "workspaceAvgMs=\(String(format: "%.2f", avgWorkspaceMs)) workspaceWorstMs=\(String(format: "%.2f", worstWorkspaceMs)) " + + "workspaceSlowCount=\(slowWorkspaceCount) waitAttempts=\(loadStats.attempts) " + + "pendingSurfaces=\(loadStats.pendingSurfaces) expectedSurfaces=\(expectedSurfaceCount)" + ) + + NSLog( + "Debug stress workspaces: created=%d panesPerWorkspace=%d tabsPerPane=%d expectedSurfaces=%d layoutFailures=%d pendingSurfaces=%d createMs=%.2f loadMs=%.2f loadedPanels=%d failedPanels=%d totalMs=%.2f workspaceAvgMs=%.2f workspaceWorstMs=%.2f waitAttempts=%d", + self.debugStressWorkspaceCount, + self.debugStressPaneCount, + self.debugStressTabsPerPane, + expectedSurfaceCount, + layoutFailures, + loadStats.pendingSurfaces, + creationElapsedMs, + loadStats.elapsedMs, + loadStats.loadedPanels, + loadStats.failedPanels, + totalElapsedMs, + avgWorkspaceMs, + worstWorkspaceMs, + loadStats.attempts + ) + } + } + + private func configureDebugStressWorkspaceLayout( + _ workspace: Workspace, + paneCount: Int, + tabsPerPane: Int + ) async -> Bool { + guard let topLeftPanelId = workspace.focusedTerminalPanel?.id ?? workspace.focusedPanelId else { + return false + } + guard let topRight = workspace.newTerminalSplit( + from: topLeftPanelId, + orientation: .horizontal, + focus: false + ) else { + return false + } + await Task.yield() + guard workspace.newTerminalSplit( + from: topLeftPanelId, + orientation: .vertical, + focus: false + ) != nil else { + return false + } + await Task.yield() + guard workspace.newTerminalSplit( + from: topRight.id, + orientation: .vertical, + focus: false + ) != nil else { + return false + } + await Task.yield() + + let paneIds = workspace.bonsplitController.allPaneIds + guard paneIds.count == paneCount else { return false } + + let additionalTabsPerPane = max(0, tabsPerPane - 1) + if additionalTabsPerPane > 0 { + for (paneIndex, paneId) in paneIds.enumerated() { + for tabOffset in 0..<additionalTabsPerPane { + guard workspace.newTerminalSurface(inPane: paneId, focus: false) != nil else { + return false + } + if ((tabOffset + 1) % debugStressYieldInterval) == 0 { + await Task.yield() + } + } + if ((paneIndex + 1) % debugStressYieldInterval) == 0 { + await Task.yield() + } + } + } + + return true + } + + private struct DebugStressSurfaceLoadStats { + let pendingSurfaces: Int + let loadedPanels: Int + let failedPanels: Int + let attempts: Int + let elapsedMs: Double + } + + private struct DebugStressTerminalLoadTarget { + let workspace: Workspace + let paneId: PaneID + let tabId: TabID + let panelId: UUID + } + + private func loadAllDebugStressWorkspacesForTerminalSurfaceReadiness( + _ workspaces: [Workspace], + tabManager: TabManager + ) async -> DebugStressSurfaceLoadStats { + guard !workspaces.isEmpty else { + return DebugStressSurfaceLoadStats( + pendingSurfaces: 0, + loadedPanels: 0, + failedPanels: 0, + attempts: 0, + elapsedMs: 0 + ) + } + + let retainedWorkspaceIds = Set(workspaces.map(\.id)) + let loadStart = ProcessInfo.processInfo.systemUptime + var attempts = 0 + var queuedTargets: [DebugStressTerminalLoadTarget] = [] + queuedTargets.reserveCapacity( + workspaces.count * debugStressPaneCount * debugStressTabsPerPane + ) + + tabManager.retainDebugWorkspaceLoads(for: retainedWorkspaceIds) + defer { tabManager.releaseDebugWorkspaceLoads(for: retainedWorkspaceIds) } + + await Task.yield() + forceDebugStressVisibleLayout() + let mountedWorkspaceCount = await waitForDebugStressMountedWorkspaces(workspaces) + + for (workspaceIndex, workspace) in workspaces.enumerated() { + for paneId in workspace.bonsplitController.allPaneIds { + for tab in workspace.bonsplitController.tabs(inPane: paneId) { + guard let panelId = workspace.panelIdFromSurfaceId(tab.id), + workspace.panel(for: tab.id) is TerminalPanel else { + continue + } + if workspace.preloadTerminalPanelForDebugStress(tabId: tab.id, inPane: paneId) != nil { + queuedTargets.append( + DebugStressTerminalLoadTarget( + workspace: workspace, + paneId: paneId, + tabId: tab.id, + panelId: panelId + ) + ) + attempts += 1 + } + } + } + + dlog( + "stress.setup.queue workspace=\(workspaceIndex + 1)/\(workspaces.count) " + + "mounted=\(mountedWorkspaceCount)/\(workspaces.count) queued=\(queuedTargets.count)" + ) + await Task.yield() + } + + let waitResult = await waitForDebugStressTerminalPanelSurfaces(queuedTargets) + attempts += waitResult.attempts + let failedPanels = waitResult.pendingTargets.count + let loadedPanels = max(0, queuedTargets.count - failedPanels) + for target in waitResult.pendingTargets { + dlog( + "stress.setup.surfaceTimeout workspace=\(target.workspace.id.uuidString.prefix(5)) " + + "panel=\(target.panelId.uuidString.prefix(5)) pane=\(target.paneId.id.uuidString.prefix(5))" + ) + } + + let elapsedMs = (ProcessInfo.processInfo.systemUptime - loadStart) * 1000.0 + return DebugStressSurfaceLoadStats( + pendingSurfaces: pendingDebugTerminalSurfaceCount(in: workspaces), + loadedPanels: loadedPanels, + failedPanels: failedPanels, + attempts: attempts, + elapsedMs: elapsedMs + ) + } + + private func waitForDebugStressMountedWorkspaces(_ workspaces: [Workspace]) async -> Int { + guard !workspaces.isEmpty else { return 0 } + var mountedWorkspaceCount = 0 + let selectedWorkspaceId = tabManager?.selectedTabId + + for _ in 0..<4 { + forceDebugStressVisibleLayout() + mountedWorkspaceCount = 0 + for workspace in workspaces { + if workspace.id == selectedWorkspaceId { + workspace.scheduleDebugStressTerminalGeometryReconcile() + } else { + workspace.requestBackgroundTerminalSurfaceStartIfNeeded() + } + if workspace.panels.values.contains(where: { panel in + guard let terminalPanel = panel as? TerminalPanel else { return false } + return terminalPanel.hostedView.superview != nil || terminalPanel.surface.surface != nil + }) { + mountedWorkspaceCount += 1 + } + } + if mountedWorkspaceCount == workspaces.count { + break + } + await Task.yield() + try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) + } + + dlog("stress.setup.mount mounted=\(mountedWorkspaceCount)/\(workspaces.count)") + return mountedWorkspaceCount + } + + private func waitForDebugStressTerminalPanelSurfaces( + _ targets: [DebugStressTerminalLoadTarget] + ) async -> (pendingTargets: [DebugStressTerminalLoadTarget], attempts: Int) { + guard !targets.isEmpty else { + return (pendingTargets: [], attempts: 0) + } + + let deadline = Date().addingTimeInterval(debugStressSurfaceLoadTimeoutSeconds) + let selectedWorkspaceId = tabManager?.selectedTabId + var pendingTargets = targets + var attempts = 0 + var pass = 0 + + while !pendingTargets.isEmpty, Date() < deadline { + pass += 1 + forceDebugStressVisibleLayout() + + var nextPending: [DebugStressTerminalLoadTarget] = [] + nextPending.reserveCapacity(pendingTargets.count) + var restartedThisPass = 0 + + for (targetIndex, target) in pendingTargets.enumerated() { + guard let terminalPanel = target.workspace.panel(for: target.tabId) as? TerminalPanel else { + nextPending.append(target) + continue + } + if terminalPanel.surface.surface != nil { + continue + } + + let hostedView = terminalPanel.hostedView + let shouldReconcileVisibleSelection = + target.workspace.id == selectedWorkspaceId && + hostedView.window != nil && + hostedView.superview != nil + + if shouldReconcileVisibleSelection { + target.workspace.scheduleDebugStressTerminalGeometryReconcile() + if pass == 1 || (pass % 4) == 0 { + if target.workspace.preloadTerminalPanelForDebugStress( + tabId: target.tabId, + inPane: target.paneId + ) != nil { + restartedThisPass += 1 + attempts += 1 + } + } else { + terminalPanel.requestViewReattach() + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } else { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + nextPending.append(target) + + if ((targetIndex + 1) % 16) == 0 { + await Task.yield() + } + } + + if nextPending.count != pendingTargets.count || restartedThisPass > 0 || pass == 1 || (pass % 8) == 0 { + dlog( + "stress.setup.await pass=\(pass) pending=\(nextPending.count) " + + "restarted=\(restartedThisPass)" + ) + } + try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) + pendingTargets = nextPending + } + + return (pendingTargets: pendingTargets, attempts: attempts) + } + + private func forceDebugStressVisibleLayout() { + if let activeWindow = NSApp.keyWindow ?? NSApp.mainWindow { + activeWindow.contentView?.layoutSubtreeIfNeeded() + activeWindow.contentView?.displayIfNeeded() return } - guard attempt < maxAttempts else { - NSLog("Debug scrollback: surface not ready after \(maxAttempts) attempts") - return + + for (windowIndex, window) in NSApp.windows.enumerated() { + window.contentView?.layoutSubtreeIfNeeded() + if windowIndex == 0 { + window.contentView?.displayIfNeeded() + } } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1) + } + + private func pendingDebugTerminalSurfaceCount(in workspaces: [Workspace]) -> Int { + var pending = 0 + for workspace in workspaces { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + if terminalPanel.surface.surface == nil { + pending += 1 + } + } } + return pending + } + + private func debugStressLagSnapshot() -> ( + workspaceCount: Int, + terminalPanelCount: Int, + loadedSurfaceCount: Int, + selectedWorkspace: String + ) { + guard let tabManager else { + return (0, 0, 0, "nil") + } + var terminalPanelCount = 0 + var loadedSurfaceCount = 0 + for workspace in tabManager.tabs { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + terminalPanelCount += 1 + if terminalPanel.surface.surface != nil { + loadedSurfaceCount += 1 + } + } + } + let selectedWorkspace = tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + return ( + tabManager.tabs.count, + terminalPanelCount, + loadedSurfaceCount, + selectedWorkspace + ) + } + + private func logSlowShortcutMonitorLatencyIfNeeded( + event: NSEvent, + handledByShortcut: Bool, + elapsedMs: Double + ) { + guard debugStressLagProbeEnabled else { return } + guard event.type == .keyDown else { return } + + let normalizedFlags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + let isPlainTyping = normalizedFlags.isDisjoint(with: [.command, .control, .option]) + let thresholdMs: Double = event.isARepeat ? 1.5 : (isPlainTyping ? 2.5 : 6.0) + guard elapsedMs >= thresholdMs else { return } + + let snapshot = debugStressLagSnapshot() + dlog( + "stress.inputLag path=appMonitor ms=\(String(format: "%.2f", elapsedMs)) " + + "threshold=\(String(format: "%.2f", thresholdMs)) handled=\(handledByShortcut ? 1 : 0) " + + "plain=\(isPlainTyping ? 1 : 0) repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) " + + "mods=\(event.modifierFlags.rawValue) workspaces=\(snapshot.workspaceCount) " + + "terminals=\(snapshot.terminalPanelCount) surfacesReady=\(snapshot.loadedSurfaceCount) " + + "selected=\(snapshot.selectedWorkspace)" + ) } @objc func triggerSentryTestCrash(_ sender: Any?) { @@ -1060,13 +6344,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Keep the test hermetic: ensure the app does not accidentally pass using a persisted // KeyboardShortcutSettings override instead of the Ghostty config-trigger path. UserDefaults.standard.removeObject(forKey: KeyboardShortcutSettings.focusLeftKey) + UserDefaults.standard.removeObject(forKey: KeyboardShortcutSettings.focusRightKey) + UserDefaults.standard.removeObject(forKey: KeyboardShortcutSettings.focusUpKey) + UserDefaults.standard.removeObject(forKey: KeyboardShortcutSettings.focusDownKey) } else { // For this UI test we want a letter-based shortcut (Cmd+Ctrl+H) to drive pane navigation, // since arrow keys can't be recorded by the shortcut recorder. - let shortcut = StoredShortcut(key: "h", command: true, shift: false, option: false, control: true) - if let data = try? JSONEncoder().encode(shortcut) { - UserDefaults.standard.set(data, forKey: KeyboardShortcutSettings.focusLeftKey) - } + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "h", command: true, shift: false, option: false, control: true), + for: .focusLeft + ) + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "l", command: true, shift: false, option: false, control: true), + for: .focusRight + ) + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "k", command: true, shift: false, option: false, control: true), + for: .focusUp + ) + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "j", command: true, shift: false, option: false, control: true), + for: .focusDown + ) } installGotoSplitUITestFocusObserversIfNeeded() @@ -1115,11 +6414,65 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - guard let self else { return } + guard self != nil else { return } runSetupWhenWindowReady() } } + private func isGotoSplitUITestRecordingEnabled() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" || env["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] == "1" + } + + private func gotoSplitUITestDataPath() -> String? { + guard isGotoSplitUITestRecordingEnabled() else { return nil } + let env = ProcessInfo.processInfo.environment + guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil } + return path + } + + private func gotoSplitFindStateSnapshot(for workspace: Workspace) -> [String: String] { + var updates: [String: String] = [ + "focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? "" + ] + + if let focusedPanelId = workspace.focusedPanelId { + updates["focusedPanelId"] = focusedPanelId.uuidString + if let terminal = workspace.terminalPanel(for: focusedPanelId) { + updates["focusedPanelKind"] = "terminal" + updates["focusedTerminalFindNeedle"] = terminal.searchState?.needle ?? "" + updates["focusedBrowserFindNeedle"] = "" + } else if let browser = workspace.browserPanel(for: focusedPanelId) { + updates["focusedPanelKind"] = "browser" + updates["focusedBrowserFindNeedle"] = browser.searchState?.needle ?? "" + updates["focusedTerminalFindNeedle"] = "" + } else { + updates["focusedPanelKind"] = "other" + updates["focusedTerminalFindNeedle"] = "" + updates["focusedBrowserFindNeedle"] = "" + } + } else { + updates["focusedPanelId"] = "" + updates["focusedPanelKind"] = "none" + updates["focusedTerminalFindNeedle"] = "" + updates["focusedBrowserFindNeedle"] = "" + } + + let terminalWithFind = workspace.panels.values + .compactMap { $0 as? TerminalPanel } + .first(where: { $0.searchState != nil }) + updates["terminalFindPanelId"] = terminalWithFind?.id.uuidString ?? "" + updates["terminalFindNeedle"] = terminalWithFind?.searchState?.needle ?? "" + + let browserWithFind = workspace.panels.values + .compactMap { $0 as? BrowserPanel } + .first(where: { $0.searchState != nil }) + updates["browserFindPanelId"] = browserWithFind?.id.uuidString ?? "" + updates["browserFindNeedle"] = browserWithFind?.searchState?.needle ?? "" + + return updates + } + private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID, attempt: Int = 0) { let maxAttempts = 120 guard attempt < maxAttempts else { @@ -1158,6 +6511,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "ghosttyGotoSplitDownShortcut": ghosttyGotoSplitDownShortcut?.displayString ?? "", "webViewFocused": "true" ]) + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] == "1" { + setupFocusedInputForGotoSplitUITest(panel: browserPanel) + } return } @@ -1203,6 +6559,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return } guard let panelId = notification.object as? UUID else { return } self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarFocus") + self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarFocus") }) gotoSplitUITestObservers.append(NotificationCenter.default.addObserver( @@ -1213,6 +6570,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return } guard let panelId = notification.object as? UUID else { return } self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarExit") + self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarExit") }) } @@ -1240,11 +6598,332 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) { + private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel, attempt: Int = 0) { + let maxAttempts = 80 + guard attempt < maxAttempts else { + writeGotoSplitTestData([ + "webInputFocusSeeded": "false", + "setupError": "Timed out focusing page input for omnibar restore test" + ]) + return + } + + let script = """ + (() => { + try { + const trackerInstalled = window.__cmuxAddressBarFocusTrackerInstalled === true; + const readyState = String(document.readyState || ""); + if (!trackerInstalled || readyState !== "complete") { + const active = document.activeElement; + return { + focused: false, + id: "", + activeId: active && typeof active.id === "string" ? active.id : "", + activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", + trackerInstalled, + trackedStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + readyState + }; + } + + const ensureInput = (id, value) => { + const existing = document.getElementById(id); + const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input") + ? existing + : (() => { + const created = document.createElement("input"); + created.id = id; + created.type = "text"; + created.value = value; + return created; + })(); + input.autocapitalize = "off"; + input.autocomplete = "off"; + input.spellcheck = false; + input.style.display = "block"; + input.style.width = "100%"; + input.style.margin = "0"; + input.style.padding = "8px 10px"; + input.style.border = "1px solid #5f6368"; + input.style.borderRadius = "6px"; + input.style.boxSizing = "border-box"; + input.style.fontSize = "14px"; + input.style.fontFamily = "system-ui, -apple-system, sans-serif"; + input.style.background = "white"; + input.style.color = "black"; + return input; + }; + + let container = document.getElementById("cmux-ui-test-focus-container"); + if (!container || !container.tagName || container.tagName.toLowerCase() !== "div") { + container = document.createElement("div"); + container.id = "cmux-ui-test-focus-container"; + document.body.appendChild(container); + } + container.style.position = "fixed"; + container.style.left = "24px"; + container.style.top = "24px"; + container.style.width = "min(520px, calc(100vw - 48px))"; + container.style.display = "grid"; + container.style.rowGap = "12px"; + container.style.padding = "12px"; + container.style.background = "rgba(255,255,255,0.92)"; + container.style.border = "1px solid rgba(95,99,104,0.55)"; + container.style.borderRadius = "8px"; + container.style.boxShadow = "0 2px 10px rgba(0,0,0,0.2)"; + container.style.zIndex = "2147483647"; + + const input = ensureInput("cmux-ui-test-focus-input", "cmux-ui-focus-primary"); + const secondaryInput = ensureInput("cmux-ui-test-focus-input-secondary", "cmux-ui-focus-secondary"); + if (input.parentElement !== container) { + container.appendChild(input); + } + if (secondaryInput.parentElement !== container) { + container.appendChild(secondaryInput); + } + + input.focus({ preventScroll: true }); + if (typeof input.setSelectionRange === "function") { + const end = input.value.length; + input.setSelectionRange(end, end); + } + + let trackedFocusId = input.getAttribute("data-cmux-addressbar-focus-id"); + if (!trackedFocusId) { + trackedFocusId = "cmux-ui-test-focus-input-tracked"; + input.setAttribute("data-cmux-addressbar-focus-id", trackedFocusId); + } + const selectionStart = typeof input.selectionStart === "number" ? input.selectionStart : null; + const selectionEnd = typeof input.selectionEnd === "number" ? input.selectionEnd : null; + if ( + !window.__cmuxAddressBarFocusState || + typeof window.__cmuxAddressBarFocusState.id !== "string" || + window.__cmuxAddressBarFocusState.id !== trackedFocusId + ) { + window.__cmuxAddressBarFocusState = { id: trackedFocusId, selectionStart, selectionEnd }; + } + + const secondaryRect = secondaryInput.getBoundingClientRect(); + const viewportWidth = Math.max(Number(window.innerWidth) || 0, 1); + const viewportHeight = Math.max(Number(window.innerHeight) || 0, 1); + const secondaryCenterX = Math.min( + 0.98, + Math.max(0.02, (secondaryRect.left + (secondaryRect.width / 2)) / viewportWidth) + ); + const secondaryCenterY = Math.min( + 0.98, + Math.max(0.02, (secondaryRect.top + (secondaryRect.height / 2)) / viewportHeight) + ); + const active = document.activeElement; + return { + focused: active === input, + id: input.id || "", + secondaryId: secondaryInput.id || "", + secondaryCenterX, + secondaryCenterY, + activeId: active && typeof active.id === "string" ? active.id : "", + activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", + trackerInstalled, + trackedStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + readyState + }; + } catch (_) { + return { + focused: false, + id: "", + secondaryId: "", + secondaryCenterX: -1, + secondaryCenterY: -1, + activeId: "", + activeTag: "", + trackerInstalled: false, + trackedStateId: "", + readyState: "" + }; + } + })(); + """ + + panel.webView.evaluateJavaScript(script) { [weak self] result, _ in + guard let self else { return } + let payload = result as? [String: Any] + let focused = (payload?["focused"] as? Bool) ?? false + let inputId = (payload?["id"] as? String) ?? "" + let secondaryInputId = (payload?["secondaryId"] as? String) ?? "" + let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1 + let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1 + let activeId = (payload?["activeId"] as? String) ?? "" + let trackerInstalled = (payload?["trackerInstalled"] as? Bool) ?? false + let trackedStateId = (payload?["trackedStateId"] as? String) ?? "" + let readyState = (payload?["readyState"] as? String) ?? "" + var secondaryClickOffsetX = -1.0 + var secondaryClickOffsetY = -1.0 + if let window = panel.webView.window { + let webFrame = panel.webView.convert(panel.webView.bounds, to: nil) + let contentHeight = Double(window.contentView?.bounds.height ?? 0) + if webFrame.width > 1, + webFrame.height > 1, + contentHeight > 1, + secondaryCenterX > 0, + secondaryCenterX < 1, + secondaryCenterY > 0, + secondaryCenterY < 1 { + let xInContent = Double(webFrame.minX) + (secondaryCenterX * Double(webFrame.width)) + let yFromTopInWeb = secondaryCenterY * Double(webFrame.height) + let yInContent = Double(webFrame.maxY) - yFromTopInWeb + let yFromTopInContent = contentHeight - yInContent + let titlebarHeight = max(0, Double(window.frame.height) - contentHeight) + secondaryClickOffsetX = xInContent + secondaryClickOffsetY = titlebarHeight + yFromTopInContent + } + } + if focused, + !inputId.isEmpty, + !secondaryInputId.isEmpty, + inputId == activeId, + trackerInstalled, + !trackedStateId.isEmpty, + secondaryCenterX > 0, + secondaryCenterX < 1, + secondaryCenterY > 0, + secondaryCenterY < 1, + secondaryClickOffsetX > 0, + secondaryClickOffsetY > 0 { + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "true", + "webInputFocusElementId": inputId, + "webInputFocusSecondaryElementId": secondaryInputId, + "webInputFocusSecondaryCenterX": "\(secondaryCenterX)", + "webInputFocusSecondaryCenterY": "\(secondaryCenterY)", + "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)", + "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)", + "webInputFocusActiveElementId": activeId, + "webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false", + "webInputFocusTrackedStateId": trackedStateId, + "webInputFocusReadyState": readyState + ]) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.setupFocusedInputForGotoSplitUITest(panel: panel, attempt: attempt + 1) + } + } + } + + private func recordGotoSplitUITestActiveElement(panelId: UUID, keyPrefix: String) { + recordGotoSplitUITestActiveElementRetry(panelId: panelId, keyPrefix: keyPrefix, attempt: 0) + } + + private func recordGotoSplitUITestActiveElementRetry(panelId: UUID, keyPrefix: String, attempt: Int) { + let delays: [Double] = [0.05, 0.1, 0.25, 0.5] + let delay = attempt < delays.count ? delays[attempt] : delays.last! + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, + let tabManager, + let tab = tabManager.selectedWorkspace, + let panel = tab.browserPanel(for: panelId) else { return } + + self.evaluateGotoSplitUITestActiveElement(panel: panel) { snapshot in + let activeId = snapshot["id"] ?? "" + let expectedInputId = self.gotoSplitUITestExpectedInputId() ?? "" + if keyPrefix == "addressBarExit", + !expectedInputId.isEmpty, + activeId != expectedInputId, + attempt < delays.count - 1 { + self.recordGotoSplitUITestActiveElementRetry( + panelId: panelId, + keyPrefix: keyPrefix, + attempt: attempt + 1 + ) + return + } + + self.writeGotoSplitTestData([ + "\(keyPrefix)PanelId": panelId.uuidString, + "\(keyPrefix)ActiveElementId": activeId, + "\(keyPrefix)ActiveElementTag": snapshot["tag"] ?? "", + "\(keyPrefix)ActiveElementType": snapshot["type"] ?? "", + "\(keyPrefix)ActiveElementEditable": snapshot["editable"] ?? "false", + "\(keyPrefix)TrackedFocusStateId": snapshot["trackedFocusStateId"] ?? "", + "\(keyPrefix)FocusTrackerInstalled": snapshot["focusTrackerInstalled"] ?? "false" + ]) + } + } + } + + private func evaluateGotoSplitUITestActiveElement( + panel: BrowserPanel, + completion: @escaping ([String: String]) -> Void + ) { + let script = """ + (() => { + try { + const active = document.activeElement; + if (!active) { + return { id: "", tag: "", type: "", editable: "false" }; + } + const tag = (active.tagName || "").toLowerCase(); + const type = (active.type || "").toLowerCase(); + const editable = + !!active.isContentEditable || + tag === "textarea" || + (tag === "input" && type !== "hidden"); + return { + id: typeof active.id === "string" ? active.id : "", + tag, + type, + editable: editable ? "true" : "false", + trackedFocusStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + focusTrackerInstalled: + window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" + }; + } catch (_) { + return { + id: "", + tag: "", + type: "", + editable: "false", + trackedFocusStateId: "", + focusTrackerInstalled: "false" + }; + } + })(); + """ + + panel.webView.evaluateJavaScript(script) { result, _ in + let payload = result as? [String: Any] + completion([ + "id": (payload?["id"] as? String) ?? "", + "tag": (payload?["tag"] as? String) ?? "", + "type": (payload?["type"] as? String) ?? "", + "editable": (payload?["editable"] as? String) ?? "false", + "trackedFocusStateId": (payload?["trackedFocusStateId"] as? String) ?? "", + "focusTrackerInstalled": (payload?["focusTrackerInstalled"] as? String) ?? "false" + ]) + } + } + + private func gotoSplitUITestExpectedInputId() -> String? { let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return } - guard let tabManager, - let focusedPaneId = tabManager.selectedWorkspace?.bonsplitController.focusedPaneId else { return } + guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil } + return loadGotoSplitTestData(at: path)["webInputFocusElementId"] + } + + private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) { + guard isGotoSplitUITestRecordingEnabled() else { return } + guard let tabManager, let workspace = tabManager.selectedWorkspace else { return } let directionValue: String switch direction { @@ -1258,15 +6937,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent directionValue = "down" } - writeGotoSplitTestData([ - "lastMoveDirection": directionValue, - "focusedPaneId": focusedPaneId.description - ]) + var updates = gotoSplitFindStateSnapshot(for: workspace) + updates["lastMoveDirection"] = directionValue + writeGotoSplitTestData(updates) } private func recordGotoSplitSplitIfNeeded(direction: SplitDirection) { - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return } + guard isGotoSplitUITestRecordingEnabled() else { return } guard let workspace = tabManager?.selectedWorkspace else { return } let directionValue: String @@ -1281,16 +6958,88 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent directionValue = "down" } - writeGotoSplitTestData([ - "lastSplitDirection": directionValue, - "paneCountAfterSplit": String(workspace.bonsplitController.allPaneIds.count), - "focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? "" - ]) + var updates = gotoSplitFindStateSnapshot(for: workspace) + updates["lastSplitDirection"] = directionValue + updates["paneCountAfterSplit"] = String(workspace.bonsplitController.allPaneIds.count) + writeGotoSplitTestData(updates) + } + + private func recordGotoSplitZoomIfNeeded() { + guard isGotoSplitUITestRecordingEnabled() else { return } + recordGotoSplitZoomRetry(attempt: 0) + } + + private func recordGotoSplitZoomRetry(attempt: Int) { + let delays: [Double] = [0.05, 0.1, 0.2, 0.35, 0.5] + let delay = attempt < delays.count ? delays[attempt] : delays.last! + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, + let workspace = self.tabManager?.selectedWorkspace else { return } + + let browserPanel = workspace.panels.values.compactMap { $0 as? BrowserPanel }.first + let otherTerminal = workspace.panels.values.compactMap { $0 as? TerminalPanel }.first + let browserSnapshot = browserPanel.flatMap { + BrowserWindowPortalRegistry.debugSnapshot(for: $0.webView) + } + + var updates = self.gotoSplitFindStateSnapshot(for: workspace) + updates["splitZoomedAfterToggle"] = workspace.bonsplitController.isSplitZoomed ? "true" : "false" + updates["zoomedPaneIdAfterToggle"] = workspace.bonsplitController.zoomedPaneId?.description ?? "" + updates["browserPanelIdAfterToggle"] = browserPanel?.id.uuidString ?? "" + updates["browserContainerHiddenAfterToggle"] = browserSnapshot.map { $0.containerHidden ? "true" : "false" } ?? "" + updates["browserVisibleFlagAfterToggle"] = browserSnapshot.map { $0.visibleInUI ? "true" : "false" } ?? "" + updates["browserFrameAfterToggle"] = browserSnapshot.map { + String( + format: "%.1f,%.1f %.1fx%.1f", + $0.frameInWindow.origin.x, + $0.frameInWindow.origin.y, + $0.frameInWindow.size.width, + $0.frameInWindow.size.height + ) + } ?? "" + updates["otherTerminalPanelIdAfterToggle"] = otherTerminal?.id.uuidString ?? "" + updates["otherTerminalHostHiddenAfterToggle"] = otherTerminal.map { $0.hostedView.isHidden ? "true" : "false" } ?? "" + updates["otherTerminalVisibleFlagAfterToggle"] = otherTerminal.map { $0.hostedView.debugPortalVisibleInUI ? "true" : "false" } ?? "" + updates["otherTerminalFrameAfterToggle"] = otherTerminal.map { + let frame = $0.hostedView.debugPortalFrameInWindow + return String( + format: "%.1f,%.1f %.1fx%.1f", + frame.origin.x, + frame.origin.y, + frame.size.width, + frame.size.height + ) + } ?? "" + + let settled: Bool = { + if workspace.bonsplitController.isSplitZoomed { + if let focusedPanelId = workspace.focusedPanelId, + workspace.terminalPanel(for: focusedPanelId) != nil { + guard let browserSnapshot else { return false } + return browserSnapshot.containerHidden && !browserSnapshot.visibleInUI + } + guard let otherTerminal else { return true } + return otherTerminal.hostedView.isHidden && !otherTerminal.hostedView.debugPortalVisibleInUI + } + let browserRestored = browserSnapshot.map { !$0.containerHidden && $0.visibleInUI } ?? true + let terminalRestored = otherTerminal.map { + !$0.hostedView.isHidden && $0.hostedView.debugPortalVisibleInUI + } ?? true + return browserRestored && terminalRestored + }() + + if !settled && attempt < delays.count - 1 { + self.recordGotoSplitZoomRetry(attempt: attempt + 1) + return + } + + self.writeGotoSplitTestData(updates) + } } private func writeGotoSplitTestData(_ updates: [String: String]) { - let env = ProcessInfo.processInfo.environment - guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return } + guard let path = gotoSplitUITestDataPath() else { return } var payload = loadGotoSplitTestData(at: path) for (key, value) in updates { payload[key] = value @@ -1317,19 +7066,64 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent try? FileManager.default.removeItem(atPath: path) - let deadline = Date().addingTimeInterval(8.0) + let contextDeadline = Date().addingTimeInterval(8.0) func waitForContexts(minCount: Int, _ completion: @escaping () -> Void) { if mainWindowContexts.count >= minCount, mainWindowContexts.values.allSatisfy({ $0.window != nil }) { completion() return } - guard Date() < deadline else { return } + guard Date() < contextDeadline else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { waitForContexts(minCount: minCount, completion) } } + func waitForSurfaceId( + on tabManager: TabManager, + tabId: UUID, + timeout: TimeInterval = 8.0, + _ completion: @escaping (UUID) -> Void + ) { + let deadline = Date().addingTimeInterval(timeout) + + func resolvedSurfaceId() -> UUID? { + if let surfaceId = tabManager.focusedPanelId(for: tabId) { + return surfaceId + } + + guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + return nil + } + + if let terminalPanelId = workspace.focusedTerminalPanel?.id { + return terminalPanelId + } + + if let terminalPanelId = workspace.terminalPanelForConfigInheritance()?.id { + return terminalPanelId + } + + return workspace.panels.values + .compactMap { ($0 as? TerminalPanel)?.id } + .sorted(by: { $0.uuidString < $1.uuidString }) + .first + } + + func poll() { + if let surfaceId = resolvedSurfaceId() { + completion(surfaceId) + return + } + guard Date() < deadline else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + poll() + } + } + + poll() + } + waitForContexts(minCount: 1) { [weak self] in guard let self else { return } guard let window1 = self.mainWindowContexts.values.first else { return } @@ -1343,39 +7137,193 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let contexts = Array(self.mainWindowContexts.values) guard let window2 = contexts.first(where: { $0.windowId != window1.windowId }) else { return } guard let tabId2 = window2.tabManager.selectedTabId ?? window2.tabManager.tabs.first?.id else { return } - guard let store = self.notificationStore else { return } + waitForSurfaceId(on: window1.tabManager, tabId: tabId1) { [weak self] surfaceId1 in + guard let self else { return } + waitForSurfaceId(on: window2.tabManager, tabId: tabId2) { [weak self] surfaceId2 in + guard let self else { return } + guard let store = self.notificationStore else { return } - // Ensure the target window is currently showing the Notifications overlay, - // so opening a notification must switch it back to the terminal UI. - window2.sidebarSelectionState.selection = .notifications + // Ensure the target window is currently showing the Notifications overlay, + // so opening a notification must switch it back to the terminal UI. + window2.sidebarSelectionState.selection = .notifications - // Create notifications for both windows. Ensure W2 isn't suppressed just because it's focused. - let prevOverride = AppFocusState.overrideIsFocused - AppFocusState.overrideIsFocused = false - store.addNotification(tabId: tabId2, surfaceId: nil, title: "W2", subtitle: "multiwindow", body: "") - AppFocusState.overrideIsFocused = prevOverride + // Create notifications for both windows. Ensure W2 isn't suppressed just because it's focused. + let prevOverride = AppFocusState.overrideIsFocused + AppFocusState.overrideIsFocused = false + store.addNotification(tabId: tabId2, surfaceId: nil, title: "W2", subtitle: "multiwindow", body: "") + AppFocusState.overrideIsFocused = prevOverride - // Insert after W2 so it becomes "latest unread" (first in list). - store.addNotification(tabId: tabId1, surfaceId: nil, title: "W1", subtitle: "multiwindow", body: "") + // Insert after W2 so it becomes "latest unread" (first in list). + store.addNotification(tabId: tabId1, surfaceId: nil, title: "W1", subtitle: "multiwindow", body: "") - let notif1 = store.notifications.first(where: { $0.tabId == tabId1 && $0.title == "W1" }) - let notif2 = store.notifications.first(where: { $0.tabId == tabId2 && $0.title == "W2" }) + let notif1 = store.notifications.first(where: { $0.tabId == tabId1 && $0.title == "W1" }) + let notif2 = store.notifications.first(where: { $0.tabId == tabId2 && $0.title == "W2" }) - self.writeMultiWindowNotificationTestData([ - "window1Id": window1.windowId.uuidString, - "window2Id": window2.windowId.uuidString, - "window2InitialSidebarSelection": "notifications", - "tabId1": tabId1.uuidString, - "tabId2": tabId2.uuidString, - "notifId1": notif1?.id.uuidString ?? "", - "notifId2": notif2?.id.uuidString ?? "", - "expectedLatestWindowId": window1.windowId.uuidString, - "expectedLatestTabId": tabId1.uuidString, - ], at: path) + self.writeMultiWindowNotificationTestData([ + "window1Id": window1.windowId.uuidString, + "window2Id": window2.windowId.uuidString, + "window2InitialSidebarSelection": "notifications", + "tabId1": tabId1.uuidString, + "tabId2": tabId2.uuidString, + "surfaceId1": surfaceId1.uuidString, + "surfaceId2": surfaceId2.uuidString, + "notifId1": notif1?.id.uuidString ?? "", + "notifId2": notif2?.id.uuidString ?? "", + "expectedLatestWindowId": window1.windowId.uuidString, + "expectedLatestTabId": tabId1.uuidString, + ], at: path) + self.prepareMultiWindowNotificationSourceTerminalIfNeeded( + at: path, + windowId: window1.windowId, + tabManager: window1.tabManager, + tabId: tabId1, + surfaceId: surfaceId1 + ) + self.publishMultiWindowNotificationSocketStateIfNeeded(at: path) + } + } } } } + private func prepareMultiWindowNotificationSourceTerminalIfNeeded( + at path: String, + windowId: UUID, + tabManager: TabManager, + tabId: UUID, + surfaceId: UUID + ) { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_NOTIFY_SOURCE_TERMINAL_READY"] == "1" else { return } + + writeMultiWindowNotificationTestData([ + "sourceTerminalReady": "pending", + "sourceTerminalFocusFailure": "", + ], at: path) + + let deadline = Date().addingTimeInterval(8.0) + + func publish(ready: Bool, failure: String = "") { + writeMultiWindowNotificationTestData([ + "sourceTerminalReady": ready ? "1" : "0", + "sourceTerminalFocusFailure": failure, + ], at: path) + } + + func poll() { + guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + publish(ready: false, failure: "workspace_missing") + return + } + guard let terminalPanel = workspace.terminalPanel(for: surfaceId) else { + publish(ready: false, failure: "terminal_missing") + return + } + + let isWindowFrontmost = { + guard let window = self.mainWindow(for: windowId) else { return false } + return NSApp.keyWindow === window || NSApp.mainWindow === window + }() + if isWindowFrontmost && terminalPanel.hostedView.isSurfaceViewFirstResponder() { + publish(ready: true) + return + } + + guard Date() < deadline else { + publish( + ready: false, + failure: isWindowFrontmost ? "terminal_not_first_responder" : "window_not_frontmost" + ) + return + } + + _ = self.focusMainWindow(windowId: windowId) + if let tab = tabManager.tabs.first(where: { $0.id == tabId }) { + tabManager.selectTab(tab) + tabManager.focusSurface(tabId: tabId, surfaceId: surfaceId) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + poll() + } + } + + poll() + } + + private func publishMultiWindowNotificationSocketStateIfNeeded(at path: String) { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return } + + guard let config = socketListenerConfigurationIfEnabled() else { + writeMultiWindowNotificationTestData([ + "socketExpectedPath": env["CMUX_SOCKET_PATH"] ?? "", + "socketMode": "off", + "socketReady": "0", + "socketPingResponse": "", + "socketIsRunning": "0", + "socketAcceptLoopAlive": "0", + "socketPathMatches": "0", + "socketPathExists": "0", + "socketFailureSignals": "socket_disabled", + ], at: path) + return + } + + writeMultiWindowNotificationTestData([ + "socketExpectedPath": config.path, + "socketMode": config.mode.rawValue, + "socketReady": "pending", + "socketPingResponse": "", + ], at: path) + + restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") + + let deadline = Date().addingTimeInterval(20.0) + func publish() { + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) + let isTimedOut = Date() >= deadline + let socketPath = config.path + let socketMode = config.mode.rawValue + let dataPath = path + + DispatchQueue.global(qos: .utility).async { [weak self] in + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + let failureSignals = { + var signals = health.failureSignals + if health.isHealthy && pingResponse != "PONG" { + signals.append("ping_timeout") + } + return signals.joined(separator: ",") + }() + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.writeMultiWindowNotificationTestData([ + "socketExpectedPath": socketPath, + "socketMode": socketMode, + "socketReady": isReady ? "1" : (isTimedOut ? "0" : "pending"), + "socketPingResponse": pingResponse ?? "", + "socketIsRunning": health.isRunning ? "1" : "0", + "socketAcceptLoopAlive": health.acceptLoopAlive ? "1" : "0", + "socketPathMatches": health.socketPathMatches ? "1" : "0", + "socketPathExists": health.socketPathExists ? "1" : "0", + "socketFailureSignals": failureSignals, + ], at: dataPath) + guard !isTimedOut, !isReady else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + publish() + } + } + } + } + + publish() + } + private func writeMultiWindowNotificationTestData(_ updates: [String: String], at path: String) { var payload = loadMultiWindowNotificationTestData(at: path) for (key, value) in updates { @@ -1430,6 +7378,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) } + @discardableResult + func dismissNotificationsPopoverIfShown() -> Bool { + titlebarAccessoryController.dismissNotificationsPopoverIfShown() + } + func jumpToLatestUnread() { guard let notificationStore else { return } #if DEBUG @@ -1450,8 +7403,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func installWindowKeyEquivalentSwizzle() { + static func installWindowResponderSwizzlesForTesting() { + _ = didInstallWindowKeyEquivalentSwizzle + _ = didInstallWindowFirstResponderSwizzle + _ = didInstallWindowSendEventSwizzle + } + +#if DEBUG + static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { + cmuxFirstResponderGuardCurrentEventOverride = currentEvent + cmuxFirstResponderGuardHitViewOverride = hitView + } + + static func clearWindowFirstResponderGuardTesting() { + cmuxFirstResponderGuardCurrentEventOverride = nil + cmuxFirstResponderGuardHitViewOverride = nil + } +#endif + + private func installWindowResponderSwizzles() { + _ = Self.didInstallApplicationSendEventSwizzle _ = Self.didInstallWindowKeyEquivalentSwizzle + _ = Self.didInstallWindowFirstResponderSwizzle + _ = Self.didInstallWindowSendEventSwizzle } private func installShortcutMonitor() { @@ -1460,32 +7434,67 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return event } if event.type == .keyDown { #if DEBUG - if (ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" - || UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")), - event.timestamp > 0 { - let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000) - let delayText = String(format: "%.2f", delayMs) - dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") + let phaseTotalStart = ProcessInfo.processInfo.systemUptime + let preludeStart = ProcessInfo.processInfo.systemUptime + var preludeMs: Double = 0 + var shortcutMs: Double = 0 + CmuxTypingTiming.logEventDelay(path: "appMonitor", event: event) + let shortcutMonitorTraceEnabled = + ProcessInfo.processInfo.environment["CMUX_SHORTCUT_MONITOR_TRACE"] == "1" + || UserDefaults.standard.bool(forKey: "cmuxShortcutMonitorTrace") + if shortcutMonitorTraceEnabled { + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" + ) } - let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")") if let probeKind = self.developerToolsShortcutProbeKind(event: event) { self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) } + preludeMs = (ProcessInfo.processInfo.systemUptime - preludeStart) * 1000.0 + let shortcutTimingStart = CmuxTypingTiming.start() #endif - if self.handleCustomShortcut(event: event) { + let shortcutStart = ProcessInfo.processInfo.systemUptime + let handledByShortcut = self.handleCustomShortcut(event: event) +#if DEBUG + shortcutMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "appMonitor.handleCustomShortcut", + startedAt: shortcutTimingStart, + event: event, + extra: "handled=\(handledByShortcut ? 1 : 0)" + ) + let shortcutElapsedMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 + self.logSlowShortcutMonitorLatencyIfNeeded( + event: event, + handledByShortcut: handledByShortcut, + elapsedMs: shortcutElapsedMs + ) + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "appMonitor.phase", + totalMs: totalMs, + event: event, + thresholdMs: 0.75, + parts: [ + ("preludeMs", preludeMs), + ("shortcutMs", shortcutMs), + ], + extra: "handled=\(handledByShortcut ? 1 : 0)" + ) +#endif + if handledByShortcut { #if DEBUG dlog(" → consumed by handleCustomShortcut") - DebugEventLog.shared.dump() #endif return nil // Consume the event } -#if DEBUG - DebugEventLog.shared.dump() -#endif return event // Pass through } self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + if self.clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) { + return nil + } return event } } @@ -1655,12 +7664,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "Quit cmux?" - alert.informativeText = "This will close all windows and workspaces." - alert.addButton(withTitle: "Quit") - alert.addButton(withTitle: "Cancel") + alert.messageText = String(localized: "dialog.quitCmux.title", defaultValue: "Quit cmux?") + alert.informativeText = String(localized: "dialog.quitCmux.message", defaultValue: "This will close all windows and workspaces.") + alert.addButton(withTitle: String(localized: "dialog.quitCmux.quit", defaultValue: "Quit")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) alert.showsSuppressionButton = true - alert.suppressionButton?.title = "Don't warn again for Cmd+Q" + alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") let response = alert.runModal() if alert.suppressionButton?.state == .on { @@ -1673,9 +7682,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + func promptRenameSelectedWorkspace() -> Bool { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + NSSound.beep() + return false + } + + let alert = NSAlert() + alert.messageText = String(localized: "dialog.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "dialog.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = String(localized: "dialog.renameWorkspace.placeholder", defaultValue: "Workspace name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return true } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) + return true + } + private func handleCustomShortcut(event: NSEvent) -> Bool { // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. - // Most shortcuts below use keyCode fallbacks, so treat nil as "" rather than bailing out. + // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. let chars = (event.charactersIgnoringModifiers ?? "").lowercased() let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let hasControl = flags.contains(.control) @@ -1700,20 +7739,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Don't steal shortcuts from close-confirmation alerts. Keep standard alert key // equivalents working and avoid surprising actions while the confirmation is up. + let closeConfirmationTitles = [ + String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), + String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), + String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), + ] let closeConfirmationPanel = NSApp.windows .compactMap { $0 as? NSPanel } .first { panel in guard panel.isVisible, let root = panel.contentView else { return false } - return findStaticText(in: root, equals: "Close workspace?") - || findStaticText(in: root, equals: "Close tab?") + return closeConfirmationTitles.contains { title in + findStaticText(in: root, equals: title) + } } if let closeConfirmationPanel { // Special-case: Cmd+D should confirm destructive close on alerts. // XCUITest key events often hit the app-level local monitor first, so forward the key // equivalent to the alert panel explicitly. - if flags == [.command], chars == "d", + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) + ), let root = closeConfirmationPanel.contentView, - let closeButton = findButton(in: root, titled: "Close") { + let closeButton = findButton( + in: root, + titled: String(localized: "common.close", defaultValue: "Close") + ) { closeButton.performClick(nil) return true } @@ -1724,14 +7776,234 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - let normalizedFlags = flags.subtracting([.numericPad, .function]) - if normalizedFlags == [.command], chars == "q" { + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) + let commandPaletteShortcutWindow = shouldHandleCommandPaletteShortcutEvent( + event, + paletteWindow: commandPaletteTargetWindow + ) ? commandPaletteTargetWindow : nil + let commandPaletteVisibleInTargetWindow = commandPaletteShortcutWindow.map { + isCommandPaletteVisible(for: $0) + } ?? false + let commandPalettePendingOpenInTargetWindow = commandPaletteTargetWindow.map { + isCommandPalettePendingOpen(for: $0) + } ?? false + let commandPaletteOverlayVisibleInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteOverlayPresented(in: $0) + } ?? false + let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteResponderActive(in: $0) + } ?? false + let commandPaletteEffectiveInTargetWindow = + commandPaletteVisibleInTargetWindow + || commandPalettePendingOpenInTargetWindow + || commandPaletteOverlayVisibleInTargetWindow + || commandPaletteResponderActiveInTargetWindow + + if normalizedFlags.isEmpty, event.keyCode == 53 { + let activePaletteWindow = activeCommandPaletteWindow() + let escapePaletteWindow: NSWindow? = { + if let targetWindow = commandPaletteTargetWindow { + guard commandPaletteEffectiveInTargetWindow else { + return nil + } + return targetWindow + } + return activePaletteWindow + }() +#if DEBUG + dlog( + "shortcut.escape route target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))} " + + "visibleTarget=\(commandPaletteVisibleInTargetWindow ? 1 : 0) " + + "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + + "effectiveTarget=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) + if commandPaletteTargetWindow != nil, + !commandPaletteVisibleInTargetWindow, + !commandPalettePendingOpenInTargetWindow, + (commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow) { + dlog( + "shortcut.escape stateMismatch target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0)" + ) + } +#endif + if let paletteWindow = escapePaletteWindow, + isCommandPaletteEffectivelyVisible(in: paletteWindow) { + if commandPaletteMarkedTextInput(in: paletteWindow) != nil { +#if DEBUG + dlog( + "shortcut.escape imeMarkedTextBypass consumed=0 target={\(debugWindowToken(paletteWindow))}" + ) +#endif + return false + } + clearCommandPalettePendingOpen(for: paletteWindow) + beginCommandPaletteEscapeSuppression(for: paletteWindow) + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: paletteWindow) +#if DEBUG + dlog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") +#endif + return true + } + let suppressionWindow = commandPaletteTargetWindow + ?? event.window + ?? NSApp.keyWindow + ?? NSApp.mainWindow + if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { +#if DEBUG + dlog( + "shortcut.escape suppressionConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "repeat=\(event.isARepeat ? 1 : 0)" + ) +#endif + return true + } + if let requestAge = recentCommandPaletteRequestAge(for: suppressionWindow) { + beginCommandPaletteEscapeSuppression(for: suppressionWindow) +#if DEBUG + dlog( + "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" + ) +#endif + return true + } +#if DEBUG + dlog( + "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))}" + ) +#endif + } + + if let delta = commandPaletteSelectionDeltaForKeyboardNavigation( + flags: event.modifierFlags, + chars: chars, + keyCode: event.keyCode + ), + commandPaletteVisibleInTargetWindow, + let paletteWindow = commandPaletteShortcutWindow { + NotificationCenter.default.post( + name: .commandPaletteMoveSelection, + object: paletteWindow, + userInfo: ["delta": delta] + ) + return true + } + + if commandPaletteVisibleInTargetWindow, + let paletteWindow = commandPaletteShortcutWindow { + let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow) + if normalizedFlags.isEmpty, event.keyCode == 53 { + if paletteFieldEditorHasMarkedText { + return false + } + NotificationCenter.default.post(name: .commandPaletteDismissRequested, object: paletteWindow) + return true + } + + if shouldSubmitCommandPaletteWithReturn( + keyCode: event.keyCode, + flags: event.modifierFlags + ) { + if paletteFieldEditorHasMarkedText { + return false + } + NotificationCenter.default.post(name: .commandPaletteSubmitRequested, object: paletteWindow) + return true + } + } + + // Guard against stale browserAddressBarFocusedPanelId after focus transitions + // (e.g., split that doesn't properly blur the address bar). If the first responder + // is a terminal surface, the address bar can't be focused. + if browserAddressBarFocusedPanelId != nil, + cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { +#if DEBUG + let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + + "reason=terminal_first_responder fr=\(firstResponderType)" + ) +#endif + browserAddressBarFocusedPanelId = nil + stopBrowserOmnibarSelectionRepeat() + } + + // Keep Cmd+P/Cmd+N inside the focused browser omnibar for Chrome-like + // suggestion navigation, and avoid opening command palette switcher. + // Scope the omnibar check to the shortcut's routed window context so a + // focused omnibar in another window does not suppress Cmd+P here. + let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil + let isCommandP = !hasFocusedAddressBarInShortcutContext + && matchShortcut( + event: event, + shortcut: StoredShortcut(key: "p", command: true, shift: false, option: false, control: false) + ) + if isCommandP { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.cmdP") + return true + } + + let isCommandShiftP = matchShortcut( + event: event, + shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) + ) + if isCommandShiftP { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP") + return true + } + + if shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, + normalizedFlags: normalizedFlags, + chars: chars, + keyCode: event.keyCode + ) { + return true + } + + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) + ) { return handleQuitShortcutWarning() } + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) + ) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.cmd_shift_comma") + return true + } + + if shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: event.modifierFlags, + chars: chars, + keyCode: event.keyCode + ) { + guard let targetWindow = mainWindowForShortcutEvent(event) else { + return false + } + targetWindow.toggleFullScreen(nil) + return true + } // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese - // input), don't intercept key events — let them flow through to the input method. - if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView, + // input), don't intercept non-Cmd key events — let them flow through to the + // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still + // work during composition since Cmd is never part of IME input sequences. + if !normalizedFlags.contains(.command), + let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), ghosttyView.hasMarkedText() { return false } @@ -1749,29 +8021,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + let hasEventWindowContext = shortcutEventHasAddressableWindow(event) + let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) + if hasEventWindowContext && !didSynchronizeShortcutContext { +#if DEBUG + dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") +#endif + return false + } + // Keep keyboard routing deterministic after split close/reparent transitions: // before processing shortcuts, converge first responder with the focused terminal panel. if isControlD { +#if DEBUG + let selected = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") +#endif tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() #if DEBUG + let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1]) #endif // Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut. return false } - // Guard against stale browserAddressBarFocusedPanelId after focus transitions - // (e.g., split that doesn't properly blur the address bar). If the first responder - // is a terminal surface, the address bar can't be focused. - if browserAddressBarFocusedPanelId != nil, - NSApp.keyWindow?.firstResponder is GhosttyNSView { -#if DEBUG - dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") -#endif - browserAddressBarFocusedPanelId = nil - stopBrowserOmnibarSelectionRepeat() - } - // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) { dispatchBrowserOmnibarSelectionMove(delta: delta) @@ -1788,6 +8065,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Fast path for normal typing and terminal navigation keys (for example Up-arrow + // history): after command-palette/notification handling and browser omnibar + // arrow navigation above, plain key events have no app-level shortcut behavior. + if normalizedFlags.isEmpty { + return false + } + // Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser // address bar is focused. Without this, app-level Cmd+N can steal focus. if shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { @@ -1796,18 +8080,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Primary UI shortcuts if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) { - sidebarState?.toggle() + _ = toggleSidebarInActiveMainWindow() return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) { +#if DEBUG + dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") +#endif // Cmd+N semantics: // - If there are no main windows, create a new window. // - Otherwise, create a new workspace in the active window. - if tabManager == nil || mainWindowContexts.isEmpty { + if mainWindowContexts.isEmpty { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "no_main_windows", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) + } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "workspace_creation_returned_nil", + event: event, + chosenContext: nil + ) + #endif openNewMainWindow(nil) - } else { - tabManager?.addTab() } return true } @@ -1827,6 +8132,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .sendFeedback)) { + guard let targetContext = preferredMainWindowContextForShortcuts(event: event), + let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { + return false + } + setActiveMainWindow(targetWindow) + bringToFront(targetWindow) + NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow) + return true + } + // Check Jump to Unread shortcut if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .jumpToUnread)) { #if DEBUG @@ -1854,6 +8170,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleTerminalCopyMode)) { + let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false +#if DEBUG + dlog( + "shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + // Only consume when a focused terminal actually handled the toggle. + // Otherwise allow the event to continue through the responder chain. + return handled + } + // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) { #if DEBUG @@ -1877,11 +8206,102 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameWorkspace)) { + return requestRenameWorkspaceViaCommandPalette( + preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + } + + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "t", command: true, shift: false, option: true, control: false) + ) { + if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + targetWindow.identifier?.rawValue == "cmux.settings" { + targetWindow.performClose(nil) + } else { + let responder = event.window?.firstResponder + ?? NSApp.keyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + if let ghosttyView = cmuxOwningGhosttyView(for: responder), + let workspaceId = ghosttyView.tabId, + let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { + manager.closeOtherTabsInFocusedPaneWithConfirmation() + } else { + tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() + } + } + return true + } + + // Cmd+W must close the focused panel even if first-responder momentarily lags on a + // browser NSTextView during split focus transitions. + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) + ) { + if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + targetWindow.identifier?.rawValue == "cmux.settings" { + targetWindow.performClose(nil) + } else { + let responder = event.window?.firstResponder + ?? NSApp.keyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + if let ghosttyView = cmuxOwningGhosttyView(for: responder), + let workspaceId = ghosttyView.tabId, + let panelId = ghosttyView.terminalSurface?.id, + let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { +#if DEBUG + dlog( + "shortcut.cmdW route=ghostty workspace=\(workspaceId.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) selected=\(manager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + ) +#endif + manager.closePanelWithConfirmation(tabId: workspaceId, surfaceId: panelId) + } else { +#if DEBUG + dlog("shortcut.cmdW route=focusedPanelFallback") +#endif + tabManager?.closeCurrentPanelWithConfirmation() + } + } + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWorkspace)) { + tabManager?.closeCurrentWorkspaceWithConfirmation() + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWindow)) { + guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return true + } + closeWindowWithConfirmation(targetWindow) + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameTab)) { + // Keep Cmd+R browser reload behavior when a browser panel is focused. + if tabManager?.focusedBrowserPanel != nil { + return false + } + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") + return true + } + // Numeric shortcuts for specific sidebar tabs: Cmd+1-9 (9 = last workspace) if flags == [.command], let manager = tabManager, let num = Int(chars), let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: num, workspaceCount: manager.tabs.count) { +#if DEBUG + dlog( + "shortcut.action name=workspaceDigit digit=\(num) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + ) +#endif manager.selectTab(at: targetIndex) return true } @@ -1948,23 +8368,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSplitZoom)) { + _ = tabManager?.toggleFocusedSplitZoom() +#if DEBUG + recordGotoSplitZoomIfNeeded() +#endif + return true + } + // Split actions: Cmd+D / Cmd+Shift+D if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) { +#if DEBUG + dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } _ = performSplitShortcut(direction: .right) return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) { +#if DEBUG + dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { + return true + } _ = performSplitShortcut(direction: .down) return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) { +#if DEBUG + dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") +#endif _ = performBrowserSplitShortcut(direction: .right) return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) { +#if DEBUG + dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") +#endif _ = performBrowserSplitShortcut(direction: .down) return true } @@ -1987,9 +8433,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Open browser: Cmd+Shift+L if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openBrowser)) { - if let panelId = tabManager?.openBrowser(insertAtEnd: true) { - focusBrowserAddressBar(panelId: panelId) - } + _ = openBrowserAndFocusAddressBar(insertAtEnd: true) return true } @@ -2027,7 +8471,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Focus browser address bar: Cmd+L - if flags == [.command] && chars == "l" { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "l", command: true, shift: false, option: false, control: false) + ) { if let focusedPanel = tabManager?.focusedBrowserPanel { focusBrowserAddressBar(in: focusedPanel) return true @@ -2038,43 +8485,297 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if let panelId = tabManager?.openBrowser(insertAtEnd: true) { - focusBrowserAddressBar(panelId: panelId) + if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { return true } } - if let action = browserZoomShortcutAction(flags: flags, chars: chars, keyCode: event.keyCode), - let manager = tabManager { + #if DEBUG + logBrowserZoomShortcutTrace(stage: "probe", event: event, flags: flags, chars: chars) + #endif + let zoomAction = browserZoomShortcutAction( + flags: flags, + chars: chars, + keyCode: event.keyCode, + literalChars: event.characters + ) + #if DEBUG + logBrowserZoomShortcutTrace(stage: "match", event: event, flags: flags, chars: chars, action: zoomAction) + #endif + if let action = zoomAction, let manager = tabManager { + let handled: Bool switch action { case .zoomIn: - return manager.zoomInFocusedBrowser() + handled = manager.zoomInFocusedBrowser() case .zoomOut: - return manager.zoomOutFocusedBrowser() + handled = manager.zoomOutFocusedBrowser() case .reset: - return manager.resetZoomFocusedBrowser() + handled = manager.resetZoomFocusedBrowser() } + #if DEBUG + logBrowserZoomShortcutTrace( + stage: "dispatch", + event: event, + flags: flags, + chars: chars, + action: action, + handled: handled + ) + #endif + return handled } + #if DEBUG + if zoomAction != nil, tabManager == nil { + logBrowserZoomShortcutTrace( + stage: "dispatch.noManager", + event: event, + flags: flags, + chars: chars, + action: zoomAction, + handled: false + ) + } + #endif return false } + private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { + return false + } + + let hostedView = terminalPanel.hostedView + let hostedSize = hostedView.bounds.size + let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor + let hostedAttachedToWindow = hostedView.window != nil + let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow + + let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: firstResponderIsWindow, + hostedSize: hostedSize, + hostedHiddenInHierarchy: hostedHiddenInHierarchy, + hostedAttachedToWindow: hostedAttachedToWindow + ) + guard shouldSuppress else { return false } + + tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() + +#if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + + "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + + "attached=\(hostedAttachedToWindow ? 1 : 0) " + + "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" + ) +#endif + return true + } + +#if DEBUG + private func logBrowserZoomShortcutTrace( + stage: String, + event: NSEvent, + flags: NSEvent.ModifierFlags, + chars: String, + action: BrowserZoomShortcutAction? = nil, + handled: Bool? = nil + ) { + guard browserZoomShortcutTraceCandidate( + flags: flags, + chars: chars, + keyCode: event.keyCode, + literalChars: event.characters + ) else { + return + } + + let keyWindow = NSApp.keyWindow + let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let panel = tabManager?.focusedBrowserPanel + let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil" + let panelZoom = panel?.webView.pageZoom ?? -1 + var line = + "zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " + + "chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " + + "action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " + + "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" + if let handled { + line += " handled=\(handled ? 1 : 0)" + } + dlog(line) + } + + private func browserFocusStateSnapshot() -> String { + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let keyWindow = NSApp.keyWindow?.windowNumber ?? -1 + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)" + } + + private func redactedDebugURL(_ url: URL?) -> String { + guard let url else { return "nil" } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return "<invalid>" + } + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.string ?? "<redacted>" + } +#endif + @discardableResult private func focusBrowserAddressBar(panelId: UUID) -> Bool { guard let tabManager, let workspace = tabManager.selectedWorkspace, let panel = workspace.browserPanel(for: panelId) else { +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + + "result=miss \(browserFocusStateSnapshot())" + ) +#endif return false } +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" + ) +#endif workspace.focusPanel(panel.id) +#if DEBUG + let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" + ) +#endif focusBrowserAddressBar(in: panel) return true } + @discardableResult + func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { + guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else { +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + + "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" + ) +#endif + return nil + } +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " + + "insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))" + ) +#endif +#if DEBUG + let didFocus = focusBrowserAddressBar(panelId: panelId) + dlog( + "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + + "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" + ) +#else + _ = focusBrowserAddressBar(panelId: panelId) +#endif + return panelId + } + private func focusBrowserAddressBar(in panel: BrowserPanel) { +#if DEBUG + let requestId = panel.requestAddressBarFocus() + dlog( + "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#else _ = panel.requestAddressBarFocus() +#endif browserAddressBarFocusedPanelId = panel.id +#if DEBUG + dlog( + "browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#endif NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) +#if DEBUG + dlog( + "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8))" + ) +#endif + } + + func focusedBrowserAddressBarPanelId() -> UUID? { + browserAddressBarFocusedPanelId + } + + private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { + guard let panelId = browserAddressBarFocusedPanelId else { return nil } + + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } + + guard let workspace = context.tabManager.selectedWorkspace else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } + + guard workspace.browserPanel(for: panelId) != nil else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + + "event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } + +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" + ) +#endif + return panelId + } + + @discardableResult + func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { + focusBrowserAddressBar(panelId: panelId) } private func shouldBypassAppShortcutForFocusedBrowserAddressBar( @@ -2082,11 +8783,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent chars: String ) -> Bool { guard browserAddressBarFocusedPanelId != nil else { return false } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags == [.control] else { return false } - return chars == "n" || chars == "p" + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return false } + let shouldBypass = chars == "n" || chars == "p" +#if DEBUG + if shouldBypass { + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + + "chars=\(chars) flags=\(normalizedFlags.rawValue)" + ) + } +#endif + return shouldBypass } private func commandOmnibarSelectionDelta( @@ -2103,6 +8813,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func dispatchBrowserOmnibarSelectionMove(delta: Int) { guard delta != 0 else { return } guard let panelId = browserAddressBarFocusedPanelId else { return } +#if DEBUG + dlog( + "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + + "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" + ) +#endif NotificationCenter.default.post( name: .browserMoveOmnibarSelection, object: panelId, @@ -2112,15 +8828,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { guard delta != 0 else { return } - guard browserAddressBarFocusedPanelId != nil else { return } + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + + "result=skip_no_focused_address_bar" + ) +#endif + return + } if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=reuse" + ) +#endif return } stopBrowserOmnibarSelectionRepeat() browserOmnibarRepeatKeyCode = keyCode browserOmnibarRepeatDelta = delta +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=armed" + ) +#endif let start = DispatchWorkItem { [weak self] in self?.scheduleBrowserOmnibarSelectionRepeatTick() @@ -2132,11 +8870,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func scheduleBrowserOmnibarSelectionRepeatTick() { browserOmnibarRepeatStartWorkItem = nil guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") +#endif stopBrowserOmnibarSelectionRepeat() return } guard browserOmnibarRepeatKeyCode != nil else { return } +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + + "delta=\(browserOmnibarRepeatDelta)" + ) +#endif dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) let tick = DispatchWorkItem { [weak self] in @@ -2147,12 +8895,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func stopBrowserOmnibarSelectionRepeat() { +#if DEBUG + let previousKeyCode = browserOmnibarRepeatKeyCode + let previousDelta = browserOmnibarRepeatDelta +#endif browserOmnibarRepeatStartWorkItem?.cancel() browserOmnibarRepeatTickWorkItem?.cancel() browserOmnibarRepeatStartWorkItem = nil browserOmnibarRepeatTickWorkItem = nil browserOmnibarRepeatKeyCode = nil browserOmnibarRepeatDelta = 0 +#if DEBUG + if previousKeyCode != nil || previousDelta != 0 { + dlog( + "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + + "delta=\(previousDelta)" + ) + } +#endif } private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { @@ -2161,11 +8921,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent switch event.type { case .keyUp: if event.keyCode == browserOmnibarRepeatKeyCode { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + + "action=stop" + ) +#endif stopBrowserOmnibarSelectionRepeat() } case .flagsChanged: let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if !flags.contains(.command) { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + + "flags=\(flags.rawValue) action=stop" + ) +#endif stopBrowserOmnibarSelectionRepeat() } default: @@ -2271,6 +9043,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + let directionLabel: String switch direction { case .left: directionLabel = "left" @@ -2336,7 +9110,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { - guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false } + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + + #if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + let selectedTabBefore = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focusedPanelBefore = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + dlog( + "split.browser.shortcut pre dir=\(directionLabel) " + + "tab=\(selectedTabBefore) focusedPanel=\(focusedPanelBefore)" + ) + #endif + + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { + #if DEBUG + dlog("split.browser.shortcut failed dir=\(directionLabel)") + #endif + return false + } + + #if DEBUG + let selectedTabAfter = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focusedPanelAfter = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + dlog( + "split.browser.shortcut post dir=\(directionLabel) " + + "created=\(panelId.uuidString.prefix(5)) tab=\(selectedTabAfter) focusedPanel=\(focusedPanelAfter)" + ) + #endif + _ = focusBrowserAddressBar(panelId: panelId) return true } @@ -2348,6 +9155,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent handleCustomShortcut(event: event) } + @discardableResult + func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameWorkspace( + preferredWindow: targetWindow, + source: "shortcut.renameWorkspace" + ) + return true + } + #if DEBUG // Debug/test hook: allow socket-driven shortcut simulation to reuse the same shortcut routing // logic as the local NSEvent monitor, without relying on AppKit event monitor behavior for @@ -2355,6 +9172,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func debugHandleCustomShortcut(event: NSEvent) -> Bool { handleCustomShortcut(event: event) } + + // Debug/test hook: mirrors local monitor routing (keyDown + keyUp lifecycle). + func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool { + if event.type == .keyDown { + return handleCustomShortcut(event: event) + } + handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) + } + + func debugMarkCommandPaletteOpenPending(window: NSWindow) { + markCommandPaletteOpenRequested(for: window) + } + + @discardableResult + func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + commandPalettePendingOpenByWindowId[windowId] = true + commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - max(age, 0) + return true + } + + // Test hook: remap a window context under a detached window key so direct + // ObjectIdentifier(window) lookups fail and fallback logic is exercised. + @discardableResult + func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = context.window ?? windowForMainWindowId(windowId) else { + return false + } + + let detachedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + debugDetachedContextWindows.append(detachedWindow) + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + mainWindowContexts[ObjectIdentifier(detachedWindow)] = context + context.window = window + return true + } #endif private func findButton(in view: NSView, titled title: String) -> NSButton? { @@ -2381,39 +9247,127 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - /// Match a shortcut against an event, handling normal keys + /// Match a shortcut against an event, handling normal keys. private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { // Some keys can include extra flags (e.g. .function) depending on the responder chain. // Strip those for consistent matching across first responders (terminal, WebKit, etc). let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + .subtracting([.numericPad, .function, .capsLock]) guard flags == shortcut.modifierFlags else { return false } - // NSEvent.charactersIgnoringModifiers preserves Shift for some symbol keys - // (e.g. Shift+] can yield "}" instead of "]"), so match brackets by keyCode. let shortcutKey = shortcut.key.lowercased() - if shortcutKey == "[" || shortcutKey == "]" { - switch event.keyCode { - case 33: // kVK_ANSI_LeftBracket - return shortcutKey == "[" - case 30: // kVK_ANSI_RightBracket - return shortcutKey == "]" - default: - return false - } + if shortcutKey == "\r" { + return event.keyCode == 36 || event.keyCode == 76 } - // Control-key combos can produce control characters (e.g. Ctrl+H => backspace), - // so fall back to keyCode matching for common printable keys. - if let chars = event.charactersIgnoringModifiers?.lowercased(), chars == shortcutKey { + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + if shortcutCharacterMatches( + eventCharacter: eventCharsIgnoringModifiers, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { return true } - if let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { + + // For command-based shortcuts, trust AppKit's layout-aware characters when present. + // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, + // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. + let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + if hasEventChars, + flags.contains(.command), + !flags.contains(.control), + shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { + return false + } + + // Match using the current keyboard layout so Command shortcuts stay character-based + // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if shortcutCharacterMatches( + eventCharacter: layoutCharacter, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return true + } + + // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), + // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for + // command punctuation shortcuts, since some non-US layouts report different characters + // for the same physical key even when menu-equivalent semantics should still apply. + let allowANSIKeyCodeFallback = flags.contains(.control) + || (flags.contains(.command) + && !flags.contains(.control) + && ( + !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) + || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) + )) + if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { return event.keyCode == expectedKeyCode } return false } + private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { + guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { + return false + } + return CharacterSet.letters.contains(scalar) + } + + private func shortcutCharacterMatches( + eventCharacter: String?, + shortcutKey: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Bool { + guard let eventCharacter, !eventCharacter.isEmpty else { return false } + if normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) == shortcutKey { + return true + } + return false + } + + private func normalizedShortcutEventCharacter( + _ eventCharacter: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> String { + let lowered = eventCharacter.lowercased() + guard applyShiftSymbolNormalization else { return lowered } + + switch lowered { + case "{": return "[" + case "}": return "]" + case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma + case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period + case "?": return "/" + case ":": return ";" + case "\"": return "'" + case "|": return "\\" + case "~": return "`" + case "+": return "=" + case "_": return "-" + case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 + case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 + case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 + case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 + case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 + case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 + case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 + case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 + case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 + case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 + default: return lowered + } + } + private func keyCodeForShortcutKey(_ key: String) -> UInt16? { // Matches macOS ANSI key codes. This is intentionally limited to keys we // support in StoredShortcut/ghostty trigger translation. @@ -2447,8 +9401,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case "-": return 27 // kVK_ANSI_Minus case "8": return 28 // kVK_ANSI_8 case "0": return 29 // kVK_ANSI_0 + case "]": return 30 // kVK_ANSI_RightBracket case "o": return 31 // kVK_ANSI_O case "u": return 32 // kVK_ANSI_U + case "[": return 33 // kVK_ANSI_LeftBracket case "i": return 34 // kVK_ANSI_I case "p": return 35 // kVK_ANSI_P case "l": return 37 // kVK_ANSI_L @@ -2463,6 +9419,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case "m": return 46 // kVK_ANSI_M case ".": return 47 // kVK_ANSI_Period case "`": return 50 // kVK_ANSI_Grave + case "\r": return 36 // kVK_Return default: return nil } @@ -2540,19 +9497,65 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func ensureApplicationIcon() { - if let icon = NSImage(named: NSImage.applicationIconName) { - NSApplication.shared.applicationIconImage = icon + let mode = AppIconSettings.resolvedMode() + if mode == .automatic { + // Let the asset catalog handle appearance-based icon selection. + if let icon = NSImage(named: NSImage.applicationIconName) { + NSApplication.shared.applicationIconImage = icon + } + } else { + AppIconSettings.applyIcon(mode) } } - private func registerLaunchServicesBundle() { - let bundleURL = Bundle.main.bundleURL.standardizedFileURL - let registerStatus = LSRegisterURL(bundleURL as CFURL, true) - if registerStatus != noErr { - NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(bundleURL.path)") + private func scheduleLaunchServicesBundleRegistration( + bundleURL: URL = Bundle.main.bundleURL.standardizedFileURL, + scheduler: @escaping (@escaping @Sendable () -> Void) -> Void = AppDelegate.enqueueLaunchServicesRegistrationWork, + register: @escaping (CFURL) -> OSStatus = { url in + LSRegisterURL(url, true) + }, + breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { message, data in + sentryBreadcrumb(message, category: "startup", data: data) + } + ) { + let normalizedURL = bundleURL.standardizedFileURL + breadcrumb("launchservices.register.schedule", [ + "bundlePath": normalizedURL.path + ]) + + scheduler { + let startedAt = CFAbsoluteTimeGetCurrent() + let registerStatus = register(normalizedURL as CFURL) + let durationMs = Int(((CFAbsoluteTimeGetCurrent() - startedAt) * 1000).rounded()) + + breadcrumb("launchservices.register.complete", [ + "bundlePath": normalizedURL.path, + "status": Int(registerStatus), + "durationMs": durationMs + ]) + + if registerStatus != noErr { + NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(normalizedURL.path)") + } } } +#if DEBUG + func scheduleLaunchServicesBundleRegistrationForTesting( + bundleURL: URL, + scheduler: @escaping (@escaping @Sendable () -> Void) -> Void, + register: @escaping (CFURL) -> OSStatus, + breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } + ) { + scheduleLaunchServicesBundleRegistration( + bundleURL: bundleURL, + scheduler: scheduler, + register: register, + breadcrumb: breadcrumb + ) + } +#endif + private func enforceSingleInstance() { guard let bundleId = Bundle.main.bundleIdentifier else { return } let currentPid = ProcessInfo.processInfo.processIdentifier @@ -2568,6 +9571,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func observeDuplicateLaunches() { guard let bundleId = Bundle.main.bundleIdentifier else { return } + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/cmux", isDirectory: false) + .standardizedFileURL + .resolvingSymlinksInPath() let currentPid = ProcessInfo.processInfo.processIdentifier workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( @@ -2578,6 +9585,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard self != nil else { return } guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } + if let executableURL = app.executableURL? + .standardizedFileURL + .resolvingSymlinksInPath(), + executableURL == embeddedCLIURL { + return + } app.terminate() if !app.isTerminated { @@ -2601,7 +9614,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - completionHandler([.banner, .sound, .list]) + var options: UNNotificationPresentationOptions = [.banner, .list] + if notification.request.content.sound != nil { + options.insert(.sound) + } + completionHandler(options) } private func handleNotificationResponse(_ response: UNNotificationResponse) { @@ -2682,6 +9699,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { [weak self] notification in guard let self else { return } guard let panelId = notification.object as? UUID else { return } + self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() if self.browserAddressBarFocusedPanelId == panelId { self.browserAddressBarFocusedPanelId = nil self.stopBrowserOmnibarSelectionRepeat() @@ -2697,17 +9715,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func setActiveMainWindow(_ window: NSWindow) { - guard isMainTerminalWindow(window) else { return } - guard let context = mainWindowContexts[ObjectIdentifier(window)] else { return } + guard let context = contextForMainTerminalWindow(window) else { return } +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) +#endif tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState TerminalController.shared.setActiveTabManager(context.tabManager) +#if DEBUG + dlog( + "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" + ) +#endif } private func unregisterMainWindow(_ window: NSWindow) { - let key = ObjectIdentifier(window) - guard let removed = mainWindowContexts.removeValue(forKey: key) else { return } + // Keep geometry available as a fallback even if the full session snapshot + // is removed when the last window closes. + persistWindowGeometry(from: window) + guard let removed = unregisterMainWindowContext(for: window) else { return } + commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) + commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) + commandPaletteEscapeSuppressionByWindowId.remove(removed.windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) // Avoid stale notifications that can no longer be opened once the owning window is gone. if let store = notificationStore { @@ -2720,8 +9754,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Repoint "active" pointers to any remaining main terminal window. let nextContext: MainWindowContext? = { if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let ctx = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { return ctx } return mainWindowContexts.values.first @@ -2739,9 +9772,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent TerminalController.shared.setActiveTabManager(nil) } } + + // During app termination we already persisted a full snapshot (with scrollback) + // in applicationShouldTerminate/applicationWillTerminate. Saving again here would + // overwrite it as windows tear down one-by-one, dropping closed windows and replay. + if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { + _ = saveSessionSnapshot( + includeScrollback: false, + removeWhenEmpty: Self.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: isTerminatingApp + ) + ) + } } private func isMainTerminalWindow(_ window: NSWindow) -> Bool { + if mainWindowContexts[ObjectIdentifier(window)] != nil { + return true + } guard let raw = window.identifier?.rawValue else { return false } return raw == "cmux.main" || raw.hasPrefix("cmux.main.") } @@ -3028,17 +10076,17 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { private var notificationsCancellable: AnyCancellable? private let buildHintTitle: String? - private let stateHintItem = NSMenuItem(title: "No unread notifications", action: nil, keyEquivalent: "") + private let stateHintItem = NSMenuItem(title: String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications"), action: nil, keyEquivalent: "") private let buildHintItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") private let notificationListSeparator = NSMenuItem.separator() private let notificationSectionSeparator = NSMenuItem.separator() - private let showNotificationsItem = NSMenuItem(title: "Show Notifications", action: nil, keyEquivalent: "") - private let jumpToUnreadItem = NSMenuItem(title: "Jump to Latest Unread", action: nil, keyEquivalent: "") - private let markAllReadItem = NSMenuItem(title: "Mark All Read", action: nil, keyEquivalent: "") - private let clearAllItem = NSMenuItem(title: "Clear All", action: nil, keyEquivalent: "") - private let checkForUpdatesItem = NSMenuItem(title: "Check for Updates…", action: nil, keyEquivalent: "") - private let preferencesItem = NSMenuItem(title: "Preferences…", action: nil, keyEquivalent: "") - private let quitItem = NSMenuItem(title: "Quit cmux", action: nil, keyEquivalent: "") + private let showNotificationsItem = NSMenuItem(title: String(localized: "statusMenu.showNotifications", defaultValue: "Show Notifications"), action: nil, keyEquivalent: "") + private let jumpToUnreadItem = NSMenuItem(title: String(localized: "statusMenu.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"), action: nil, keyEquivalent: "") + private let markAllReadItem = NSMenuItem(title: String(localized: "statusMenu.markAllRead", defaultValue: "Mark All Read"), action: nil, keyEquivalent: "") + private let clearAllItem = NSMenuItem(title: String(localized: "statusMenu.clearAll", defaultValue: "Clear All"), action: nil, keyEquivalent: "") + private let checkForUpdatesItem = NSMenuItem(title: String(localized: "menu.checkForUpdates", defaultValue: "Check for Updates…"), action: nil, keyEquivalent: "") + private let preferencesItem = NSMenuItem(title: String(localized: "menu.preferences", defaultValue: "Preferences…"), action: nil, keyEquivalent: "") + private let quitItem = NSMenuItem(title: String(localized: "menu.quitCmux", defaultValue: "Quit cmux"), action: nil, keyEquivalent: "") private var notificationItems: [NSMenuItem] = [] private let maxInlineNotificationItems = 6 @@ -3154,6 +10202,9 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { stateHintItem.title = snapshot.stateHintTitle + applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem) + applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem) + jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications markAllReadItem.isEnabled = snapshot.hasUnreadNotifications clearAllItem.isEnabled = snapshot.hasNotifications @@ -3164,10 +10215,22 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { button.image = MenuBarIconRenderer.makeImage(unreadCount: displayedUnreadCount) button.toolTip = displayedUnreadCount == 0 ? "cmux" - : "cmux: \(displayedUnreadCount) unread notification\(displayedUnreadCount == 1 ? "" : "s")" + : displayedUnreadCount == 1 + ? "cmux: " + String(localized: "statusMenu.tooltip.unread.one", defaultValue: "1 unread notification") + : "cmux: " + String(localized: "statusMenu.tooltip.unread.other", defaultValue: "\(displayedUnreadCount) unread notifications") } } + private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { + guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + return + } + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierFlags + } + private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) { for item in notificationItems { menu.removeItem(item) @@ -3277,9 +10340,14 @@ enum NotificationMenuSnapshotBuilder { } static func stateHintTitle(unreadCount: Int) -> String { - unreadCount == 0 - ? "No unread notifications" - : "\(unreadCount) unread notification\(unreadCount == 1 ? "" : "s")" + switch unreadCount { + case 0: + return String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications") + case 1: + return String(localized: "statusMenu.unreadCount.one", defaultValue: "1 unread notification") + default: + return String(localized: "statusMenu.unreadCount.other", defaultValue: "\(unreadCount) unread notifications") + } } } @@ -3581,6 +10649,7 @@ enum MenuBarIconRenderer { drawBadge(text: text, in: config.badgeRect, config: config) } + image.isTemplate = true return image } @@ -3609,7 +10678,7 @@ enum MenuBarIconRenderer { path.line(to: map(384.0, 369.0)) path.close() - NSColor.white.setFill() + NSColor.black.setFill() path.fill() } @@ -3635,9 +10704,295 @@ enum MenuBarIconRenderer { } +#if DEBUG +private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent? +private var cmuxFirstResponderGuardHitViewOverride: NSView? +#endif +private var cmuxFirstResponderGuardCurrentEventContext: NSEvent? +private var cmuxFirstResponderGuardHitViewContext: NSView? +private var cmuxFirstResponderGuardContextWindowNumber: Int? +private var cmuxBrowserReturnForwardingDepth = 0 +private var cmuxWindowFirstResponderBypassDepth = 0 +private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 + +@discardableResult +func cmuxWithWindowFirstResponderBypass<T>(_ body: () -> T) -> T { + cmuxWindowFirstResponderBypassDepth += 1 + defer { + cmuxWindowFirstResponderBypassDepth = max(0, cmuxWindowFirstResponderBypassDepth - 1) + } + return body() +} + +func cmuxIsWindowFirstResponderBypassActive() -> Bool { + cmuxWindowFirstResponderBypassDepth > 0 +} + +private final class CmuxFieldEditorOwningWebViewBox: NSObject { + weak var webView: CmuxWebView? + + init(webView: CmuxWebView?) { + self.webView = webView + } +} + +private extension NSApplication { + @objc func cmux_applicationSendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? CmuxTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + if event.type == .keyDown { + CmuxTypingTiming.logEventDelay(path: "app.sendEvent", event: event) + } + defer { + if event.type == .keyDown { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "app.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [("dispatchMs", totalMs)] + ) + CmuxTypingTiming.logDuration( + path: "app.sendEvent", + startedAt: typingTimingStart, + event: event + ) + } + } +#endif + cmux_applicationSendEvent(event) + } +} + private extension NSWindow { + @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { + if cmuxIsWindowFirstResponderBypassActive() { +#if DEBUG + dlog( + "focus.guard bypassFirstResponder responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + + let currentEvent = Self.cmuxCurrentEvent(for: self) + let responderWebView = responder.flatMap { + Self.cmuxOwningWebView(for: $0, in: self, event: currentEvent) + } + var pointerInitiatedWebFocus = false + + if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: self, + responder: responder + ) == true { +#if DEBUG + dlog( + "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + + if let responder, + let webView = responderWebView, + !webView.allowsFirstResponderAcquisitionEffective { + let pointerInitiatedFocus = Self.cmuxShouldAllowPointerInitiatedWebViewFocus( + window: self, + webView: webView, + event: currentEvent + ) + if pointerInitiatedFocus { + pointerInitiatedWebFocus = true +#if DEBUG + dlog( + "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + } else { +#if DEBUG + dlog( + "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + return false + } + } +#if DEBUG + if let responder, + let webView = responderWebView { + dlog( + "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth)" + ) + } +#endif + let result: Bool + if pointerInitiatedWebFocus, let webView = responderWebView { + // `NSWindow.makeFirstResponder` may run before `CmuxWebView.mouseDown(with:)`. + // Preserve pointer intent during this synchronous responder change. + result = webView.withPointerFocusAllowance { + cmux_makeFirstResponder(responder) + } + } else { + result = cmux_makeFirstResponder(responder) + } + if result { + if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { + Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor { + Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } + } + return result + } + + @objc func cmux_sendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? CmuxTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + var contextSetupMs: Double = 0 + var folderGuardMs: Double = 0 + var originalDispatchMs: Double = 0 + let typingTimingExtra: String? = { + guard event.type == .keyDown else { return nil } + let responderWebView = self.firstResponder.flatMap { + Self.cmuxOwningWebView(for: $0, in: self, event: event) + } + let hitWebView = Self.cmuxHitViewForEventDispatch(in: self, event: event).flatMap { + Self.cmuxOwningWebView(for: $0) + } + let firstResponderType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "browser=\((responderWebView != nil || hitWebView != nil) ? 1 : 0) firstResponder=\(firstResponderType)" + }() + if event.type == .keyDown { + CmuxTypingTiming.logEventDelay(path: "window.sendEvent", event: event) + } +#endif + // recordTypingActivity must run in all builds so runSessionAutosaveTick + // can honor the typing quiet period in release. + if event.type == .keyDown { + AppDelegate.shared?.recordTypingActivity() + } +#if DEBUG + defer { + if event.type == .keyDown { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "window.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [ + ("contextSetupMs", contextSetupMs), + ("folderGuardMs", folderGuardMs), + ("originalDispatchMs", originalDispatchMs), + ], + extra: typingTimingExtra + ) + CmuxTypingTiming.logDuration( + path: "window.sendEvent", + startedAt: typingTimingStart, + event: event, + extra: typingTimingExtra + ) + } + } + let contextSetupStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + let previousContextEvent = cmuxFirstResponderGuardCurrentEventContext + let previousContextHitView = cmuxFirstResponderGuardHitViewContext + let previousContextWindowNumber = cmuxFirstResponderGuardContextWindowNumber + cmuxFirstResponderGuardCurrentEventContext = event + cmuxFirstResponderGuardHitViewContext = Self.cmuxHitViewForEventDispatch(in: self, event: event) + cmuxFirstResponderGuardContextWindowNumber = self.windowNumber +#if DEBUG + if event.type == .keyDown { + contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0 + } + let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + defer { + cmuxFirstResponderGuardCurrentEventContext = previousContextEvent + cmuxFirstResponderGuardHitViewContext = previousContextHitView + cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber + } + + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), + let contentView = self.contentView else { +#if DEBUG + if event.type == .keyDown { + folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 + let originalDispatchStart = ProcessInfo.processInfo.systemUptime + cmux_sendEvent(event) + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + return + } +#endif + cmux_sendEvent(event) + return + } +#if DEBUG + if event.type == .keyDown { + folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 + } + let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + let previousMovableState = isMovable + if previousMovableState { + isMovable = false + } + + #if DEBUG + let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" + dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") + #endif + + cmux_sendEvent(event) +#if DEBUG + if event.type == .keyDown { + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + } +#endif + + if previousMovableState { + isMovable = previousMovableState + } + + #if DEBUG + dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + #endif + } + @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "window.performKeyEquivalent", + startedAt: typingTimingStart, + event: event + ) + } let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") #endif @@ -3657,10 +11012,16 @@ private extension NSWindow { // Command shortcuts when the terminal is focused — the local event monitor // (handleCustomShortcut) already handles app-level shortcuts, and anything // remaining should be menu items. - if let ghosttyView = self.firstResponder as? GhosttyNSView { - // If the IME is composing, don't intercept key events — let them flow - // through normal AppKit event dispatch so the input method can process them. - if ghosttyView.hasMarkedText() { + let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) + let firstResponderWebView = self.firstResponder.flatMap { + Self.cmuxOwningWebView(for: $0, in: self, event: event) + } + if let ghosttyView = firstResponderGhosttyView { + // If the IME is composing and the key has no Cmd modifier, don't intercept — + // let it flow through normal AppKit event dispatch so the input method can + // process it. Cmd-based shortcuts should still work during composition since + // Cmd is never part of IME input sequences. + if ghosttyView.hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { return cmux_performKeyEquivalent(with: event) } @@ -3672,6 +11033,49 @@ private extension NSWindow { #endif return result } + + // Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when + // the terminal is focused. Otherwise our browser menu shortcuts can + // consume the event even when no browser panel is focused. + if shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) { + ghosttyView.keyDown(with: event) +#if DEBUG + dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") +#endif + return true + } + } + + // Web forms rely on Return/Enter flowing through keyDown. If the original + // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches + // WebKit. Route Return/Enter directly to the current first responder and + // mark handled to avoid the AppKit alert sound path. + if shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsBrowser: firstResponderWebView != nil, + flags: event.modifierFlags + ) { + // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. + // On re-entry, fall back to normal dispatch to avoid an infinite loop. + if cmuxBrowserReturnForwardingDepth > 0 { +#if DEBUG + dlog(" → browser Return/Enter reentry; using normal dispatch") +#endif + return false + } + cmuxBrowserReturnForwardingDepth += 1 + defer { cmuxBrowserReturnForwardingDepth = max(0, cmuxBrowserReturnForwardingDepth - 1) } +#if DEBUG + dlog(" → browser Return/Enter routed to firstResponder.keyDown") +#endif + self.firstResponder?.keyDown(with: event) + return true } if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { @@ -3684,13 +11088,31 @@ private extension NSWindow { // When the terminal is focused, skip the full NSWindow.performKeyEquivalent // (which walks the SwiftUI content view hierarchy) and dispatch Command-key // events directly to the main menu. This avoids the broken SwiftUI focus path. - if self.firstResponder is GhosttyNSView, - event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), - let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) { + if firstResponderGhosttyView != nil, + shouldRouteCommandEquivalentDirectlyToMainMenu(event), + let mainMenu = NSApp.mainMenu { + let consumedByMenu = mainMenu.performKeyEquivalent(with: event) #if DEBUG - dlog(" → consumed by mainMenu (bypassed SwiftUI)") + if browserZoomShortcutTraceCandidate( + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) { + dlog( + "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + + "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" + ) + } #endif - return true + if !consumedByMenu { + // Fall through to the original performKeyEquivalent path below. + } else { +#if DEBUG + dlog(" → consumed by mainMenu (bypassed SwiftUI)") +#endif + return true + } } let result = cmux_performKeyEquivalent(with: event) @@ -3711,4 +11133,228 @@ private extension NSWindow { parts.append("'\(chars)'(\(event.keyCode))") return parts.joined(separator: "+") } + + private static func cmuxOwningWebView(for responder: NSResponder) -> CmuxWebView? { + if let webView = responder as? CmuxWebView { + return webView + } + + if let view = responder as? NSView, + let webView = cmuxOwningWebView(for: view) { + return webView + } + + // NSTextView.delegate is unsafe-unretained in AppKit. Reading it here while + // a responder chain is tearing down can trap with "unowned reference". + var current = responder.nextResponder + while let next = current { + if let webView = next as? CmuxWebView { + return webView + } + if let view = next as? NSView, + let webView = cmuxOwningWebView(for: view) { + return webView + } + current = next.nextResponder + } + + return nil + } + + private static func cmuxOwningWebView( + for responder: NSResponder, + in window: NSWindow, + event: NSEvent? + ) -> CmuxWebView? { + if let webView = cmuxOwningWebView(for: responder) { + return webView + } + + guard let textView = responder as? NSTextView, textView.isFieldEditor else { + return nil + } + + if let event, + let hitWebView = cmuxPointerHitWebView(in: window, event: event) { + cmuxTrackFieldEditor(textView, owningWebView: hitWebView) + return hitWebView + } + + return cmuxTrackedOwningWebView(for: textView) + } + + private static func cmuxOwningWebView(for view: NSView) -> CmuxWebView? { + if let webView = view as? CmuxWebView { + return webView + } + + var current: NSView? = view.superview + while let candidate = current { + if let webView = candidate as? CmuxWebView { + return webView + } + if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"), + let portalWebView = cmuxUniqueBrowserWebView(in: candidate) { + // Portal-hosted browser chrome (for example the Cmd+F overlay) is a + // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a + // descendant of it. Treating every view in that slot as "web-owned" + // blocks legitimate first-responder changes to overlay text fields. + if view === portalWebView || view.isDescendant(of: portalWebView) { + return portalWebView + } + return nil + } + current = candidate.superview + } + + return nil + } + + private static func cmuxUniqueBrowserWebView(in root: NSView) -> CmuxWebView? { + var stack: [NSView] = [root] + var found: CmuxWebView? + while let current = stack.popLast() { + if let webView = current as? CmuxWebView { + if found == nil { + found = webView + } else if found !== webView { + return nil + } + } + stack.append(contentsOf: current.subviews) + } + return found + } + + private static func cmuxCurrentEvent(for window: NSWindow) -> NSEvent? { +#if DEBUG + if let override = cmuxFirstResponderGuardCurrentEventOverride { + return override + } +#endif + if cmuxFirstResponderGuardContextWindowNumber == window.windowNumber { + return cmuxFirstResponderGuardCurrentEventContext + } + return NSApp.currentEvent + } + + private static func cmuxHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) + return themeFrame.hitTest(pointInTheme) + } + + private static func cmuxHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView else { + return nil + } + let pointInContent = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(pointInContent) + } + + private static func cmuxTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? { + if let hitInThemeFrame = cmuxHitViewInThemeFrame(in: window, event: event) { + return hitInThemeFrame + } + return cmuxHitViewInContentView(in: window, event: event) + } + + private static func cmuxHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? { + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + return cmuxTopHitViewForEvent(in: window, event: event) + } + + private static func cmuxHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { +#if DEBUG + if let override = cmuxFirstResponderGuardHitViewOverride { + return override + } +#endif + if cmuxFirstResponderGuardContextWindowNumber == window.windowNumber, + let contextHitView = cmuxFirstResponderGuardHitViewContext { + return contextHitView + } + return cmuxTopHitViewForEvent(in: window, event: event) + } + + private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) { + if let webView { + objc_setAssociatedObject( + fieldEditor, + &cmuxFieldEditorOwningWebViewAssociationKey, + CmuxFieldEditorOwningWebViewBox(webView: webView), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } else { + objc_setAssociatedObject( + fieldEditor, + &cmuxFieldEditorOwningWebViewAssociationKey, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static func cmuxTrackedOwningWebView(for fieldEditor: NSTextView) -> CmuxWebView? { + guard let box = objc_getAssociatedObject( + fieldEditor, + &cmuxFieldEditorOwningWebViewAssociationKey + ) as? CmuxFieldEditorOwningWebViewBox else { + return nil + } + guard let webView = box.webView else { + cmuxTrackFieldEditor(fieldEditor, owningWebView: nil) + return nil + } + return webView + } + + private static func cmuxIsPointerDownEvent(_ event: NSEvent) -> Bool { + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private static func cmuxPointerHitWebView(in window: NSWindow, event: NSEvent) -> CmuxWebView? { + guard cmuxIsPointerDownEvent(event) else { return nil } + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + if let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint( + event.locationInWindow, + in: window + ) as? CmuxWebView { + return portalWebView + } + guard let hitView = cmuxHitViewForCurrentEvent(in: window, event: event) else { + return nil + } + return cmuxOwningWebView(for: hitView) + } + + private static func cmuxShouldAllowPointerInitiatedWebViewFocus( + window: NSWindow, + webView: CmuxWebView, + event: NSEvent? + ) -> Bool { + guard let event, + let hitWebView = cmuxPointerHitWebView(in: window, event: event) else { + return false + } + return hitWebView === webView + } } diff --git a/Sources/AppleScriptSupport.swift b/Sources/AppleScriptSupport.swift new file mode 100644 index 00000000..640750d5 --- /dev/null +++ b/Sources/AppleScriptSupport.swift @@ -0,0 +1,705 @@ +import AppKit + +private enum AppleScriptStrings { + static let disabled = String( + localized: "applescript.error.disabled", + defaultValue: "AppleScript is disabled by the macos-applescript configuration." + ) + static let missingAction = String( + localized: "applescript.error.missingAction", + defaultValue: "Missing action string." + ) + static let missingInputText = String( + localized: "applescript.error.missingInputText", + defaultValue: "Missing input text." + ) + static let missingTerminalTarget = String( + localized: "applescript.error.missingTerminalTarget", + defaultValue: "Missing terminal target." + ) + static let missingSplitDirection = String( + localized: "applescript.error.missingSplitDirection", + defaultValue: "Missing or unknown split direction." + ) + static let windowUnavailable = String( + localized: "applescript.error.windowUnavailable", + defaultValue: "Window is no longer available." + ) + static let workspaceUnavailable = String( + localized: "applescript.error.workspaceUnavailable", + defaultValue: "Workspace is no longer available." + ) + static let terminalUnavailable = String( + localized: "applescript.error.terminalUnavailable", + defaultValue: "Terminal is no longer available." + ) + static let failedToCreateWindow = String( + localized: "applescript.error.failedToCreateWindow", + defaultValue: "Failed to create window." + ) + static let failedToCreateWorkspace = String( + localized: "applescript.error.failedToCreateWorkspace", + defaultValue: "Failed to create workspace." + ) + static let failedToCreateSplit = String( + localized: "applescript.error.failedToCreateSplit", + defaultValue: "Failed to create split." + ) +} + +private extension String { + var fourCharCode: UInt32 { + utf8.reduce(0) { ($0 << 8) + UInt32($1) } + } +} + +private extension Workspace { + func scriptingTerminalPanels() -> [TerminalPanel] { + var results: [TerminalPanel] = [] + var seen: Set<UUID> = [] + + for panelId in sidebarOrderedPanelIds() { + guard seen.insert(panelId).inserted, + let terminal = terminalPanel(for: panelId) else { + continue + } + results.append(terminal) + } + + let remaining = panels.values + .compactMap { $0 as? TerminalPanel } + .sorted { $0.id.uuidString < $1.id.uuidString } + + for terminal in remaining where seen.insert(terminal.id).inserted { + results.append(terminal) + } + + return results + } +} + +@MainActor +extension NSApplication { + var isAppleScriptEnabled: Bool { + GhosttyApp.shared.appleScriptAutomationEnabled() + } + + @discardableResult + func validateScript(command: NSScriptCommand) -> Bool { + guard isAppleScriptEnabled else { + command.scriptErrorNumber = errAEEventNotPermitted + command.scriptErrorString = AppleScriptStrings.disabled + return false + } + + return true + } + + @objc(scriptWindows) + var scriptWindows: [ScriptWindow] { + guard isAppleScriptEnabled, + let appDelegate = AppDelegate.shared else { + return [] + } + return appDelegate.scriptableMainWindows().map { ScriptWindow(windowId: $0.windowId) } + } + + @objc(frontWindow) + var frontWindow: ScriptWindow? { + scriptWindows.first + } + + @objc(valueInScriptWindowsWithUniqueID:) + func valueInScriptWindows(uniqueID: String) -> ScriptWindow? { + guard isAppleScriptEnabled, + let windowId = UUID(uuidString: uniqueID), + let appDelegate = AppDelegate.shared, + appDelegate.scriptableMainWindow(windowId: windowId) != nil else { + return nil + } + return ScriptWindow(windowId: windowId) + } + + @objc(terminals) + var terminals: [ScriptTerminal] { + guard isAppleScriptEnabled, + let appDelegate = AppDelegate.shared else { + return [] + } + + return appDelegate.scriptableMainWindows() + .flatMap { state in + state.tabManager.tabs.flatMap { workspace in + workspace.scriptingTerminalPanels().map { + ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id) + } + } + } + } + + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + guard isAppleScriptEnabled, + let terminalId = UUID(uuidString: uniqueID), + let appDelegate = AppDelegate.shared else { + return nil + } + + for state in appDelegate.scriptableMainWindows() { + for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil { + return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId) + } + } + + return nil + } + + @objc(handlePerformActionScriptCommand:) + func handlePerformActionScriptCommand(_ command: NSScriptCommand) -> NSNumber? { + guard validateScript(command: command) else { return nil } + + guard let action = command.directParameter as? String else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = AppleScriptStrings.missingAction + return nil + } + + guard let terminal = command.evaluatedArguments?["on"] as? ScriptTerminal else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = AppleScriptStrings.missingTerminalTarget + return nil + } + + return NSNumber(value: terminal.perform(action: action)) + } + + @objc(handleNewWindowScriptCommand:) + func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> ScriptWindow? { + guard validateScript(command: command) else { return nil } + + guard let appDelegate = AppDelegate.shared else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.failedToCreateWindow + return nil + } + + let windowId = appDelegate.createMainWindow() + return ScriptWindow(windowId: windowId) + } + + @objc(handleNewTabScriptCommand:) + func handleNewTabScriptCommand(_ command: NSScriptCommand) -> ScriptTab? { + guard validateScript(command: command) else { return nil } + + guard let appDelegate = AppDelegate.shared else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace + return nil + } + + if let targetWindow = command.evaluatedArguments?["window"] as? ScriptWindow { + guard let workspaceId = appDelegate.addWorkspace(windowId: targetWindow.windowId, bringToFront: false) else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace + return nil + } + return ScriptTab(windowId: targetWindow.windowId, tabId: workspaceId) + } + + if let frontWindow = scriptWindows.first, + let workspaceId = appDelegate.addWorkspace(windowId: frontWindow.windowId, bringToFront: false) { + return ScriptTab(windowId: frontWindow.windowId, tabId: workspaceId) + } + + let windowId = appDelegate.createMainWindow() + return ScriptWindow(windowId: windowId).selectedTab + } + + @objc(handleQuitScriptCommand:) + func handleQuitScriptCommand(_ command: NSScriptCommand) { + guard validateScript(command: command) else { return } + terminate(nil) + } +} + +@MainActor +@objc(CmuxScriptWindow) +final class ScriptWindow: NSObject { + let windowId: UUID + + init(windowId: UUID) { + self.windowId = windowId + } + + private var state: AppDelegate.ScriptableMainWindowState? { + AppDelegate.shared?.scriptableMainWindow(windowId: windowId) + } + + @objc(id) + var idValue: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return windowId.uuidString + } + + @objc(title) + var title: String { + guard NSApp.isAppleScriptEnabled, + let state else { + return "" + } + + let windowTitle = state.window?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !windowTitle.isEmpty { + return windowTitle + } + + return state.tabManager.selectedWorkspace?.title ?? "" + } + + @objc(tabs) + var tabs: [ScriptTab] { + guard NSApp.isAppleScriptEnabled, + let state else { + return [] + } + return state.tabManager.tabs.map { ScriptTab(windowId: windowId, tabId: $0.id) } + } + + @objc(selectedTab) + var selectedTab: ScriptTab? { + guard NSApp.isAppleScriptEnabled, + let selectedId = state?.tabManager.selectedTabId else { + return nil + } + return ScriptTab(windowId: windowId, tabId: selectedId) + } + + @objc(terminals) + var terminals: [ScriptTerminal] { + guard NSApp.isAppleScriptEnabled, + let state else { + return [] + } + return state.tabManager.tabs.flatMap { workspace in + workspace.scriptingTerminalPanels().map { + ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id) + } + } + } + + @objc(valueInTabsWithUniqueID:) + func valueInTabs(uniqueID: String) -> ScriptTab? { + guard NSApp.isAppleScriptEnabled, + let tabId = UUID(uuidString: uniqueID), + let state, + state.tabManager.tabs.contains(where: { $0.id == tabId }) else { + return nil + } + return ScriptTab(windowId: windowId, tabId: tabId) + } + + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + guard NSApp.isAppleScriptEnabled, + let terminalId = UUID(uuidString: uniqueID), + let state else { + return nil + } + + for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil { + return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId) + } + + return nil + } + + @objc(handleActivateWindowCommand:) + func handleActivateWindow(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard AppDelegate.shared?.focusScriptableMainWindow(windowId: windowId, bringToFront: true) == true else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.windowUnavailable + return nil + } + + return nil + } + + @objc(handleCloseWindowCommand:) + func handleCloseWindow(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let window = state?.window else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.windowUnavailable + return nil + } + + window.performClose(nil) + return nil + } + + override var objectSpecifier: NSScriptObjectSpecifier? { + guard NSApp.isAppleScriptEnabled, + let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else { + return nil + } + + return NSUniqueIDSpecifier( + containerClassDescription: appClassDescription, + containerSpecifier: nil, + key: "scriptWindows", + uniqueID: windowId.uuidString + ) + } +} + +@MainActor +@objc(CmuxScriptTab) +final class ScriptTab: NSObject { + let windowId: UUID + let tabId: UUID + + init(windowId: UUID, tabId: UUID) { + self.windowId = windowId + self.tabId = tabId + } + + private var state: AppDelegate.ScriptableMainWindowState? { + AppDelegate.shared?.scriptableMainWindow(windowId: windowId) + } + + private var workspace: Workspace? { + state?.tabManager.tabs.first(where: { $0.id == tabId }) + } + + private var window: ScriptWindow { + ScriptWindow(windowId: windowId) + } + + @objc(id) + var idValue: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return tabId.uuidString + } + + @objc(title) + var title: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return workspace?.title ?? "" + } + + @objc(index) + var index: Int { + guard NSApp.isAppleScriptEnabled, + let state, + let idx = state.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + return 0 + } + return idx + 1 + } + + @objc(selected) + var selected: Bool { + guard NSApp.isAppleScriptEnabled else { return false } + return state?.tabManager.selectedTabId == tabId + } + + @objc(focusedTerminal) + var focusedTerminal: ScriptTerminal? { + guard NSApp.isAppleScriptEnabled, + let terminalId = workspace?.focusedTerminalPanel?.id else { + return nil + } + return ScriptTerminal(workspaceId: tabId, terminalId: terminalId) + } + + @objc(terminals) + var terminals: [ScriptTerminal] { + guard NSApp.isAppleScriptEnabled, + let workspace else { + return [] + } + return workspace.scriptingTerminalPanels().map { + ScriptTerminal(workspaceId: tabId, terminalId: $0.id) + } + } + + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + guard NSApp.isAppleScriptEnabled, + let workspace, + let terminalId = UUID(uuidString: uniqueID), + workspace.terminalPanel(for: terminalId) != nil else { + return nil + } + return ScriptTerminal(workspaceId: tabId, terminalId: terminalId) + } + + @objc(handleSelectTabCommand:) + func handleSelectTab(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let state, + let workspace else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.workspaceUnavailable + return nil + } + + state.tabManager.selectWorkspace(workspace) + return nil + } + + @objc(handleCloseTabCommand:) + func handleCloseTab(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let state, + let workspace else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.workspaceUnavailable + return nil + } + + if state.tabManager.tabs.count > 1 { + state.tabManager.closeWorkspace(workspace) + return nil + } + + guard let window = state.window else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.windowUnavailable + return nil + } + + window.performClose(nil) + return nil + } + + override var objectSpecifier: NSScriptObjectSpecifier? { + guard NSApp.isAppleScriptEnabled, + let windowClassDescription = window.classDescription as? NSScriptClassDescription, + let windowSpecifier = window.objectSpecifier else { + return nil + } + + return NSUniqueIDSpecifier( + containerClassDescription: windowClassDescription, + containerSpecifier: windowSpecifier, + key: "tabs", + uniqueID: tabId.uuidString + ) + } +} + +@MainActor +@objc(CmuxScriptTerminal) +final class ScriptTerminal: NSObject { + let workspaceId: UUID + let terminalId: UUID + + init(workspaceId: UUID, terminalId: UUID) { + self.workspaceId = workspaceId + self.terminalId = terminalId + } + + private var state: AppDelegate.ScriptableMainWindowState? { + AppDelegate.shared?.scriptableMainWindowForTab(workspaceId) + } + + private var workspace: Workspace? { + state?.tabManager.tabs.first(where: { $0.id == workspaceId }) + } + + private var terminal: TerminalPanel? { + workspace?.terminalPanel(for: terminalId) + } + + @objc(id) + var stableID: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return terminalId.uuidString + } + + @objc(title) + var title: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return terminal?.displayTitle ?? "" + } + + @objc(workingDirectory) + var workingDirectory: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return terminal?.directory ?? "" + } + + func input(text: String) -> Bool { + guard NSApp.isAppleScriptEnabled, + let terminal else { + return false + } + terminal.sendText(text) + return true + } + + func perform(action: String) -> Bool { + guard NSApp.isAppleScriptEnabled else { return false } + return terminal?.performBindingAction(action) ?? false + } + + @objc(handleSplitCommand:) + func handleSplit(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let directionCode = command.evaluatedArguments?["direction"] as? UInt32, + let direction = ScriptSplitDirection(code: directionCode)?.splitDirection else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = AppleScriptStrings.missingSplitDirection + return nil + } + + guard let state, + let workspace, + terminal != nil else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + + guard let newPanelId = state.tabManager.newSplit(tabId: workspaceId, surfaceId: terminalId, direction: direction), + workspace.terminalPanel(for: newPanelId) != nil else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.failedToCreateSplit + return nil + } + + return ScriptTerminal(workspaceId: workspaceId, terminalId: newPanelId) + } + + @objc(handleFocusCommand:) + func handleFocus(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let state, + let workspace, + terminal != nil else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + + if let app = AppDelegate.shared { + _ = app.focusScriptableMainWindow(windowId: state.windowId, bringToFront: true) + } + state.tabManager.selectWorkspace(workspace) + workspace.focusPanel(terminalId) + return nil + } + + @objc(handleCloseCommand:) + func handleClose(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let state, + let workspace, + terminal != nil else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + + if workspace.panels.count == 1 { + if state.tabManager.tabs.count > 1 { + state.tabManager.closeWorkspace(workspace) + return nil + } + + guard let window = state.window else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.windowUnavailable + return nil + } + + window.performClose(nil) + return nil + } + + guard workspace.closePanel(terminalId, force: true) else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspaceId, surfaceId: terminalId) + return nil + } + + override var objectSpecifier: NSScriptObjectSpecifier? { + guard NSApp.isAppleScriptEnabled, + let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else { + return nil + } + + return NSUniqueIDSpecifier( + containerClassDescription: appClassDescription, + containerSpecifier: nil, + key: "terminals", + uniqueID: terminalId.uuidString + ) + } +} + +@MainActor +@objc(CmuxScriptInputTextCommand) +final class ScriptInputTextCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard NSApp.validateScript(command: self) else { return nil } + + guard let text = directParameter as? String else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = AppleScriptStrings.missingInputText + return nil + } + + guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = AppleScriptStrings.missingTerminalTarget + return nil + } + + guard terminal.input(text: text) else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = AppleScriptStrings.terminalUnavailable + return nil + } + return nil + } +} + +private enum ScriptSplitDirection { + case right + case left + case down + case up + + init?(code: UInt32) { + switch code { + case "GSrt".fourCharCode: self = .right + case "GSlf".fourCharCode: self = .left + case "GSdn".fourCharCode: self = .down + case "GSup".fourCharCode: self = .up + default: return nil + } + } + + var splitDirection: SplitDirection { + switch self { + case .right: return .right + case .left: return .left + case .down: return .down + case .up: return .up + } + } +} diff --git a/Sources/Backport.swift b/Sources/Backport.swift index d1bb5461..b6a1ec3b 100644 --- a/Sources/Backport.swift +++ b/Sources/Backport.swift @@ -7,6 +7,15 @@ struct Backport<Content> { extension View { var backport: Backport<Self> { Backport(content: self) } + + @ViewBuilder + func safeHelp(_ text: String) -> some View { + if text.isEmpty { + self + } else { + self.help(text) + } + } } extension Scene { diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 8da7833c..07393dbe 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1,12 +1,13 @@ import AppKit -import ObjectiveC -import WebKit -#if DEBUG import Bonsplit -#endif +import ObjectiveC +import SwiftUI +import WebKit private var cmuxWindowBrowserPortalKey: UInt8 = 0 private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0 +private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0 +private var cmuxBrowserPortalNeedsRenderingStateReattachKey: UInt8 = 0 #if DEBUG private func browserPortalDebugToken(_ view: NSView?) -> String { @@ -20,12 +21,253 @@ private func browserPortalDebugFrame(_ rect: NSRect) -> String { } #endif +private extension NSObject { + @discardableResult + func browserPortalCallVoidIfAvailable(_ rawSelector: String) -> Bool { + let selector = NSSelectorFromString(rawSelector) + guard responds(to: selector) else { return false } + typealias Fn = @convention(c) (AnyObject, Selector) -> Void + let fn = unsafeBitCast(method(for: selector), to: Fn.self) + fn(self, selector) + return true + } +} + +private extension NSResponder { + var browserPortalOwningView: NSView? { + if let editor = self as? NSTextView, + editor.isFieldEditor, + let editedView = editor.delegate as? NSView { + return editedView + } + return self as? NSView + } +} + +private extension WKWebView { + private var browserPortalNeedsRenderingStateReattach: Bool { + get { + (objc_getAssociatedObject(self, &cmuxBrowserPortalNeedsRenderingStateReattachKey) as? NSNumber)? + .boolValue ?? false + } + set { + objc_setAssociatedObject( + self, + &cmuxBrowserPortalNeedsRenderingStateReattachKey, + NSNumber(value: newValue), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + func browserPortalNotifyHidden(reason: String) { + browserPortalNeedsRenderingStateReattach = true + let firedSelectors = ["viewDidHide", "_exitInWindow"].filter { + browserPortalCallVoidIfAvailable($0) + } +#if DEBUG + if !firedSelectors.isEmpty { + dlog( + "browser.portal.webview.hidden web=\(browserPortalDebugToken(self)) " + + "reason=\(reason) selectors=\(firedSelectors.joined(separator: ","))" + ) + } +#endif + } + + func browserPortalReattachRenderingState(reason: String) { + guard browserPortalNeedsRenderingStateReattach else { return } + guard window != nil else { return } + browserPortalNeedsRenderingStateReattach = false + + let firedSelectors = [ + "viewDidUnhide", + "_enterInWindow", + "_endDeferringViewInWindowChangesSync", + ].filter { + browserPortalCallVoidIfAvailable($0) + } + + if let scrollView = enclosingScrollView { + scrollView.needsLayout = true + scrollView.needsDisplay = true + scrollView.setNeedsDisplay(scrollView.bounds) + scrollView.contentView.needsLayout = true + scrollView.contentView.needsDisplay = true + } + + needsLayout = true + needsDisplay = true + setNeedsDisplay(bounds) + +#if DEBUG + if !firedSelectors.isEmpty { + dlog( + "browser.portal.webview.reattach web=\(browserPortalDebugToken(self)) " + + "reason=\(reason) selectors=\(firedSelectors.joined(separator: ",")) " + + "frame=\(browserPortalDebugFrame(frame))" + ) + } +#endif + } +} + +enum HostedInspectorDockSide { + case leading + case trailing + + static func resolve( + pageFrame: NSRect, + inspectorFrame: NSRect, + epsilon: CGFloat = 1 + ) -> Self? { + if pageFrame.maxX <= inspectorFrame.minX + epsilon { + return .trailing + } + if inspectorFrame.maxX <= pageFrame.minX + epsilon { + return .leading + } + return nil + } + + func dividerX(pageFrame: NSRect, inspectorFrame: NSRect) -> CGFloat { + switch self { + case .leading: + return inspectorFrame.maxX + case .trailing: + return inspectorFrame.minX + } + } + + func dividerHitRect( + in bounds: NSRect, + pageFrame: NSRect, + inspectorFrame: NSRect, + expansion: CGFloat + ) -> NSRect { + return NSRect( + x: dividerX(pageFrame: pageFrame, inspectorFrame: inspectorFrame) - expansion, + y: bounds.minY, + width: expansion * 2, + height: max(0, bounds.height) + ) + } + + func clampedDividerX( + _ proposedDividerX: CGFloat, + containerBounds: NSRect, + pageFrame: NSRect, + minimumInspectorWidth: CGFloat + ) -> CGFloat { + switch self { + case .leading: + let minDividerX = min(containerBounds.maxX, containerBounds.minX + minimumInspectorWidth) + let maxDividerX = max(minDividerX, min(containerBounds.maxX, pageFrame.maxX)) + return max(minDividerX, min(maxDividerX, proposedDividerX)) + case .trailing: + let minDividerX = max(containerBounds.minX, pageFrame.minX) + let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth) + return max(minDividerX, min(maxDividerX, proposedDividerX)) + } + } + + func inspectorWidth(forDividerX dividerX: CGFloat, in containerBounds: NSRect) -> CGFloat { + switch self { + case .leading: + return max(0, dividerX - containerBounds.minX) + case .trailing: + return max(0, containerBounds.maxX - dividerX) + } + } + + func resizedFrames( + preferredWidth: CGFloat, + in containerBounds: NSRect, + pageFrame: NSRect, + inspectorFrame: NSRect, + minimumInspectorWidth: CGFloat + ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { + let normalizedMinY = containerBounds.minY + let normalizedHeight = max(0, containerBounds.height) + + switch self { + case .leading: + let maximumInspectorWidth = max(0, containerBounds.width) + let clampedMinimumInspectorWidth = min(maximumInspectorWidth, max(0, minimumInspectorWidth)) + let clampedInspectorWidth = min( + maximumInspectorWidth, + max(clampedMinimumInspectorWidth, preferredWidth) + ) + let dividerX = min(containerBounds.maxX, containerBounds.minX + clampedInspectorWidth) + + var nextPageFrame = pageFrame + nextPageFrame.origin.x = dividerX + nextPageFrame.origin.y = normalizedMinY + nextPageFrame.size.width = max(0, containerBounds.maxX - dividerX) + nextPageFrame.size.height = normalizedHeight + + var nextInspectorFrame = inspectorFrame + nextInspectorFrame.origin.x = containerBounds.minX + nextInspectorFrame.origin.y = normalizedMinY + nextInspectorFrame.size.width = max(0, dividerX - containerBounds.minX) + nextInspectorFrame.size.height = normalizedHeight + return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame) + + case .trailing: + let maximumInspectorWidth = max(0, containerBounds.width) + let clampedMinimumInspectorWidth = min(maximumInspectorWidth, max(0, minimumInspectorWidth)) + let clampedInspectorWidth = min( + maximumInspectorWidth, + max(clampedMinimumInspectorWidth, preferredWidth) + ) + let dividerX = max(containerBounds.minX, containerBounds.maxX - clampedInspectorWidth) + + var nextPageFrame = pageFrame + nextPageFrame.origin.x = containerBounds.minX + nextPageFrame.origin.y = normalizedMinY + nextPageFrame.size.width = max(0, dividerX - containerBounds.minX) + nextPageFrame.size.height = normalizedHeight + + var nextInspectorFrame = inspectorFrame + nextInspectorFrame.origin.x = dividerX + nextInspectorFrame.origin.y = normalizedMinY + nextInspectorFrame.size.width = max(0, containerBounds.maxX - dividerX) + nextInspectorFrame.size.height = normalizedHeight + return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame) + } + } +} + final class WindowBrowserHostView: NSView { private struct DividerRegion { let rectInWindow: NSRect let isVertical: Bool } + private struct DividerHit { + let kind: DividerCursorKind + let isInHostedContent: Bool + } + + private struct HostedInspectorDividerHit { + let slotView: WindowBrowserSlotView + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + let dockSide: HostedInspectorDockSide + } + + private struct HostedInspectorDividerDragState { + let slotView: WindowBrowserSlotView + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + let dockSide: HostedInspectorDockSide + let initialWindowX: CGFloat + let initialPageFrame: NSRect + let initialInspectorFrame: NSRect + } + private enum DividerCursorKind: Equatable { case vertical case horizontal @@ -41,10 +283,62 @@ final class WindowBrowserHostView: NSView { override var isOpaque: Bool { false } private static let sidebarLeadingEdgeEpsilon: CGFloat = 1 private static let minimumVisibleLeadingContentWidth: CGFloat = 24 + private static let hostedInspectorDividerHitExpansion: CGFloat = 6 + private static let minimumHostedInspectorWidth: CGFloat = 120 private var cachedSidebarDividerX: CGFloat? private var sidebarDividerMissCount = 0 private var trackingArea: NSTrackingArea? private var activeDividerCursorKind: DividerCursorKind? + private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? + private var lastHostedInspectorLayoutBoundsSize: NSSize? + + deinit { + if let trackingArea { + removeTrackingArea(trackingArea) + } + clearActiveDividerCursor(restoreArrow: false) + } + +#if DEBUG + private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool { + switch event?.type { + case .leftMouseDown, .leftMouseDragged, .leftMouseUp: + return true + default: + return false + } + } + + private func debugLogPointerRouting( + stage: String, + point: NSPoint, + titlebarPassThrough: Bool, + sidebarPassThrough: Bool, + dividerHit: DividerHit?, + hitView: NSView? + ) { + let event = NSApp.currentEvent + guard Self.shouldLogPointerEvent(event) else { return } + + let hitDesc: String = { + guard let hitView else { return "nil" } + return "\(type(of: hitView))@\(browserPortalDebugToken(hitView))" + }() + let dividerDesc: String = { + guard let dividerHit else { return "nil" } + let kind = dividerHit.kind == .vertical ? "vertical" : "horizontal" + return "kind=\(kind),hosted=\(dividerHit.isInHostedContent ? 1 : 0)" + }() + let windowPoint = convert(point, to: nil) + dlog( + "browser.portal.pointer stage=\(stage) event=\(String(describing: event?.type)) " + + "host=\(browserPortalDebugToken(self)) point=\(browserPortalDebugFrame(NSRect(origin: point, size: .zero))) " + + "windowPoint=\(browserPortalDebugFrame(NSRect(origin: windowPoint, size: .zero))) " + + "titlebar=\(titlebarPassThrough ? 1 : 0) sidebar=\(sidebarPassThrough ? 1 : 0) " + + "divider=\(dividerDesc) hit=\(hitDesc)" + ) + } +#endif override func viewDidMoveToWindow() { super.viewDidMoveToWindow() @@ -64,9 +358,34 @@ final class WindowBrowserHostView: NSView { window?.invalidateCursorRects(for: self) } + override func layout() { + super.layout() + if let previousSize = lastHostedInspectorLayoutBoundsSize, + Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) { + return + } + lastHostedInspectorLayoutBoundsSize = bounds.size + reapplyHostedInspectorDividersIfNeeded(reason: "host.layout") + } + + override func didAddSubview(_ subview: NSView) { + super.didAddSubview(subview) + guard let slot = subview as? WindowBrowserSlotView else { return } + slot.onHostedInspectorLayout = { [weak self] slotView in + self?.reapplyHostedInspectorDividerIfNeeded(in: slotView, reason: "slot.layout") + } + } + + override func willRemoveSubview(_ subview: NSView) { + if let slot = subview as? WindowBrowserSlotView { + slot.onHostedInspectorLayout = nil + } + super.willRemoveSubview(subview) + } + override func resetCursorRects() { super.resetCursorRects() - guard let window, let rootView = window.contentView else { return } + guard let rootView = dividerSearchRootView() else { return } var regions: [DividerRegion] = [] Self.collectSplitDividerRegions(in: rootView, into: ®ions) let expansion: CGFloat = 4 @@ -115,19 +434,259 @@ final class WindowBrowserHostView: NSView { } override func hitTest(_ point: NSPoint) -> NSView? { - updateDividerCursor(at: point) + let dividerHit = splitDividerHit(at: point) + let hostedInspectorHit = dividerHit == nil ? hostedInspectorDividerHit(at: point) : nil + updateDividerCursor(at: point, dividerHit: dividerHit, hostedInspectorHit: hostedInspectorHit) - if shouldPassThroughToSidebarResizer(at: point) { + let titlebarPassThrough = shouldPassThroughToTitlebar(at: point) + let sidebarPassThrough = shouldPassThroughToSidebarResizer( + at: point, + dividerHit: dividerHit, + hostedInspectorHit: hostedInspectorHit + ) + let splitPassThrough = dividerHit.map { !$0.isInHostedContent } ?? false + + if titlebarPassThrough { +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.titlebarPass", + point: point, + titlebarPassThrough: true, + sidebarPassThrough: sidebarPassThrough, + dividerHit: dividerHit, + hitView: nil + ) +#endif return nil } - if shouldPassThroughToSplitDivider(at: point) { + if sidebarPassThrough { +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.sidebarPass", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: true, + dividerHit: dividerHit, + hitView: nil + ) +#endif return nil } + if splitPassThrough { +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.splitPass", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: false, + dividerHit: dividerHit, + hitView: nil + ) +#endif + return nil + } + // Mirror terminal portal routing: while tab-reorder drags are active, + // pass through to SwiftUI drop targets behind the portal host. + // Browser hover routing also arrives as cursor/enter events and may not + // report a pressed-button state, so include that path here. + if Self.shouldPassThroughToDragTargets( + pasteboardTypes: NSPasteboard(name: .drag).types, + eventType: NSApp.currentEvent?.type + ) { + return nil + } + + if let hostedInspectorHit { + if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) { +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.hostedInspectorNative", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: false, + dividerHit: DividerHit(kind: .vertical, isInHostedContent: true), + hitView: nativeHit + ) +#endif + return nativeHit + } +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.hostedInspectorManual", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: false, + dividerHit: DividerHit(kind: .vertical, isInHostedContent: true), + hitView: hostedInspectorHit.inspectorView + ) +#endif + return self + } let hitView = super.hitTest(point) +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.result", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: false, + dividerHit: dividerHit, + hitView: hitView === self ? nil : hitView + ) +#endif return hitView === self ? nil : hitView } + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else { + super.mouseDown(with: event) + return + } + + hostedInspectorHit.slotView.isHostedInspectorDividerDragActive = true + hostedInspectorDividerDrag = HostedInspectorDividerDragState( + slotView: hostedInspectorHit.slotView, + containerView: hostedInspectorHit.containerView, + pageView: hostedInspectorHit.pageView, + inspectorView: hostedInspectorHit.inspectorView, + dockSide: hostedInspectorHit.dockSide, + initialWindowX: event.locationInWindow.x, + initialPageFrame: hostedInspectorHit.pageView.frame, + initialInspectorFrame: hostedInspectorHit.inspectorView.frame + ) +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=start slot=\(browserPortalDebugToken(hostedInspectorHit.slotView)) " + + "page=\(browserPortalDebugToken(hostedInspectorHit.pageView)) " + + "inspector=\(browserPortalDebugToken(hostedInspectorHit.inspectorView)) " + + "pageFrame=\(browserPortalDebugFrame(hostedInspectorHit.pageView.frame)) " + + "inspectorFrame=\(browserPortalDebugFrame(hostedInspectorHit.inspectorView.frame))" + ) +#endif + } + + override func mouseDragged(with event: NSEvent) { + guard let dragState = hostedInspectorDividerDrag else { + super.mouseDragged(with: event) + return + } + guard dragState.slotView.window === window else { + dragState.slotView.isHostedInspectorDividerDragActive = false + hostedInspectorDividerDrag = nil + super.mouseDragged(with: event) + return + } + + let containerBounds = dragState.containerView.bounds + let minimumInspectorWidth = min( + Self.minimumHostedInspectorWidth, + max(60, dragState.initialInspectorFrame.width) + ) + let initialDividerX = dragState.dockSide.dividerX( + pageFrame: dragState.initialPageFrame, + inspectorFrame: dragState.initialInspectorFrame + ) + let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX) + let clampedDividerX = dragState.dockSide.clampedDividerX( + proposedDividerX, + containerBounds: containerBounds, + pageFrame: dragState.initialPageFrame, + minimumInspectorWidth: minimumInspectorWidth + ) + let inspectorWidth = dragState.dockSide.inspectorWidth( + forDividerX: clampedDividerX, + in: containerBounds + ) + + dragState.slotView.recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds) + let appliedFrames = applyHostedInspectorDividerWidth( + inspectorWidth, + to: HostedInspectorDividerHit( + slotView: dragState.slotView, + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide + ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: "drag" + ) + updateDividerCursor( + at: convert(event.locationInWindow, from: nil), + dividerHit: nil, + hostedInspectorHit: HostedInspectorDividerHit( + slotView: dragState.slotView, + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide + ) + ) +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=update slot=\(browserPortalDebugToken(dragState.slotView)) " + + "dividerX=\(String(format: "%.1f", clampedDividerX)) " + + "pageFrame=\(browserPortalDebugFrame(appliedFrames.pageFrame)) " + + "inspectorFrame=\(browserPortalDebugFrame(appliedFrames.inspectorFrame))" + ) +#endif + } + + override func mouseUp(with event: NSEvent) { + if let dragState = hostedInspectorDividerDrag { + dragState.slotView.isHostedInspectorDividerDragActive = false +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=end slot=\(browserPortalDebugToken(dragState.slotView)) " + + "pageFrame=\(browserPortalDebugFrame(dragState.pageView.frame)) " + + "inspectorFrame=\(browserPortalDebugFrame(dragState.inspectorView.frame))" + ) +#endif + scheduleHostedInspectorDividerReapply(in: dragState.slotView, reason: "dragEndAsync") + } + hostedInspectorDividerDrag = nil + updateDividerCursor(at: convert(event.locationInWindow, from: nil)) + super.mouseUp(with: event) + } + + private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool { + guard let window else { return false } + // Window-level portal hosts sit above SwiftUI content. Never intercept + // 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) + 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 func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { + let dividerHit = splitDividerHit(at: point) + let hostedInspectorHit = dividerHit == nil ? hostedInspectorDividerHit(at: point) : nil + return shouldPassThroughToSidebarResizer( + at: point, + dividerHit: dividerHit, + hostedInspectorHit: hostedInspectorHit + ) + } + + private func shouldPassThroughToSidebarResizer( + at point: NSPoint, + dividerHit: DividerHit?, + hostedInspectorHit: HostedInspectorDividerHit? = nil + ) -> Bool { + // If WebKit has a hosted vertical inspector split collapsed to the pane edge, + // prefer that divider over the app/sidebar resize hit zone. + if let dividerHit, + dividerHit.isInHostedContent, + dividerHit.kind == .vertical { + return false + } + if hostedInspectorHit != nil { + return false + } + // Browser portal host sits above SwiftUI content. Allow pointer/mouse events // to reach the SwiftUI sidebar divider resizer zone. let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView } @@ -178,13 +737,24 @@ final class WindowBrowserHostView: NSView { return point.x >= regionMinX && point.x <= regionMaxX } - private func updateDividerCursor(at point: NSPoint) { - if shouldPassThroughToSidebarResizer(at: point) { + private func updateDividerCursor( + at point: NSPoint, + dividerHit: DividerHit? = nil, + hostedInspectorHit: HostedInspectorDividerHit? = nil + ) { + let resolvedDividerHit = dividerHit ?? splitDividerHit(at: point) + let resolvedHostedInspectorHit = resolvedDividerHit == nil ? (hostedInspectorHit ?? hostedInspectorDividerHit(at: point)) : nil + if shouldPassThroughToSidebarResizer( + at: point, + dividerHit: resolvedDividerHit, + hostedInspectorHit: resolvedHostedInspectorHit + ) { clearActiveDividerCursor(restoreArrow: false) return } - guard let nextKind = splitDividerCursorKind(at: point) else { + let nextKind = resolvedDividerHit?.kind ?? (resolvedHostedInspectorHit == nil ? nil : .vertical) + guard let nextKind else { clearActiveDividerCursor(restoreArrow: true) return } @@ -192,6 +762,26 @@ final class WindowBrowserHostView: NSView { nextKind.cursor.set() } + private func nativeHostedInspectorHit( + at point: NSPoint, + hostedInspectorHit: HostedInspectorDividerHit + ) -> NSView? { + guard let nativeHit = super.hitTest(point), nativeHit !== self else { return nil } + if nativeHit === hostedInspectorHit.pageView || + nativeHit.isDescendant(of: hostedInspectorHit.pageView) { + return nil + } + if nativeHit === hostedInspectorHit.inspectorView || + nativeHit.isDescendant(of: hostedInspectorHit.inspectorView) { + return nativeHit + } + if hostedInspectorHit.inspectorView.isDescendant(of: nativeHit), + !(hostedInspectorHit.pageView === nativeHit || hostedInspectorHit.pageView.isDescendant(of: nativeHit)) { + return nativeHit + } + return nil + } + private func clearActiveDividerCursor(restoreArrow: Bool) { guard activeDividerCursorKind != nil else { return } window?.invalidateCursorRects(for: self) @@ -201,18 +791,270 @@ final class WindowBrowserHostView: NSView { } } - private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? { - guard let window else { return nil } + private func splitDividerHit(at point: NSPoint) -> DividerHit? { + guard window != nil else { return nil } let windowPoint = convert(point, to: nil) - guard let rootView = window.contentView else { return nil } - return Self.dividerCursorKind(at: windowPoint, in: rootView) + guard let rootView = dividerSearchRootView() else { return nil } + return Self.dividerHit(at: windowPoint, in: rootView, hostView: self) + } + + private func dividerSearchRootView() -> NSView? { + if let container = superview { + return container + } + return window?.contentView } private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { - splitDividerCursorKind(at: point) != nil + guard let dividerHit = splitDividerHit(at: point) else { return false } + // Portal host should pass split-divider events through to app layout splits, + // but keep WebKit inspector/internal split dividers interactive. + return !dividerHit.isInHostedContent } - private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? { + static func shouldPassThroughToDragTargets( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + if DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting( + pasteboardTypes: pasteboardTypes, + eventType: eventType + ) { + return true + } + + guard let eventType else { return false } + switch eventType { + case .cursorUpdate, .mouseEntered, .mouseExited, .mouseMoved: + // Browser-side tab drags can surface as hover events with a mixed + // pasteboard payload (tabtransfer plus promised-file UTIs). Prefer + // the explicit Bonsplit drag types so WKWebView cannot steal the + // session as a file upload. + return DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + || DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes) + default: + return false + } + } + + private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? { + let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView } + .filter { !$0.isHidden && $0.window != nil && $0.frame.height > 1 } + + for slot in visibleSlots { + let pointInSlot = slot.convert(point, from: self) + guard slot.bounds.contains(pointInSlot), + let hit = hostedInspectorDividerCandidate(in: slot) else { + continue + } + + if hostedInspectorDividerHitRect(for: hit).contains(pointInSlot) { + return hit + } + } + + return nil + } + + private func hostedInspectorDividerCandidate(in slot: WindowBrowserSlotView) -> HostedInspectorDividerHit? { + let inspectorCandidates = Self.visibleDescendants(in: slot) + .filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) } + .sorted { lhs, rhs in + let lhsFrame = slot.convert(lhs.bounds, from: lhs) + let rhsFrame = slot.convert(rhs.bounds, from: rhs) + return lhsFrame.minX < rhsFrame.minX + } + + var bestHit: HostedInspectorDividerHit? + var bestScore = -CGFloat.greatestFiniteMagnitude + + for inspectorCandidate in inspectorCandidates { + guard let candidate = hostedInspectorDividerCandidate(in: slot, startingAt: inspectorCandidate) else { + continue + } + let score = hostedInspectorDividerCandidateScore(candidate) + if score > bestScore { + bestScore = score + bestHit = candidate + } + } + + return bestHit + } + + private func hostedInspectorDividerCandidate( + in slot: WindowBrowserSlotView, + startingAt inspectorLeaf: NSView + ) -> HostedInspectorDividerHit? { + var current: NSView? = inspectorLeaf + var bestHit: HostedInspectorDividerHit? + + while let inspectorView = current, inspectorView !== slot { + guard let containerView = inspectorView.superview else { break } + + let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in + guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil } + guard candidate !== inspectorView else { return nil } + guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { + return nil + } + guard let dockSide = HostedInspectorDockSide.resolve( + pageFrame: candidate.frame, + inspectorFrame: inspectorView.frame + ) else { + return nil + } + return (view: candidate, dockSide: dockSide) + } + + if let pageCandidate = pageCandidates.max(by: { + hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView) + < hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView) + }) { + bestHit = HostedInspectorDividerHit( + slotView: slot, + containerView: containerView, + pageView: pageCandidate.view, + inspectorView: inspectorView, + dockSide: pageCandidate.dockSide + ) + } + + current = containerView + } + + return bestHit + } + + private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect { + let slotBounds = hit.slotView.bounds + let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView) + let inspectorFrame = hit.slotView.convert(hit.inspectorView.bounds, from: hit.inspectorView) + return hit.dockSide.dividerHitRect( + in: slotBounds, + pageFrame: pageFrame, + inspectorFrame: inspectorFrame, + expansion: Self.hostedInspectorDividerHitExpansion + ) + } + + private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat { + let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView) + let inspectorFrame = hit.slotView.convert(hit.inspectorView.bounds, from: hit.inspectorView) + let overlap = Self.verticalOverlap(between: pageFrame, and: inspectorFrame) + let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX) + return (overlap * 1_000) + coverageWidth + pageFrame.width + } + + private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat { + let overlap = Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame) + let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX) + return (overlap * 1_000) + coverageWidth + pageView.frame.width + } + + private func reapplyHostedInspectorDividersIfNeeded(reason: String) { + let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView } + .filter { !$0.isHidden && $0.window != nil && $0.frame.height > 1 } + for slot in visibleSlots { + reapplyHostedInspectorDividerIfNeeded(in: slot, reason: reason) + } + } + + private func scheduleHostedInspectorDividerReapply(in slot: WindowBrowserSlotView, reason: String) { + guard slot.preferredHostedInspectorWidth != nil else { return } + DispatchQueue.main.async { [weak self, weak slot] in + guard let self, let slot, slot.isDescendant(of: self) else { return } + self.reapplyHostedInspectorDividerIfNeeded(in: slot, reason: reason) + } + } + + @discardableResult + fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) -> Bool { + guard !slot.isHostedInspectorDividerDragActive else { +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=skipReapply slot=\(browserPortalDebugToken(slot)) " + + "reason=\(reason)" + ) +#endif + return false + } + guard let preferredWidth = slot.resolvedPreferredHostedInspectorWidth(in: slot.bounds) else { return false } + guard let hit = hostedInspectorDividerCandidate(in: slot) else { return false } + let oldPageFrame = hit.pageView.frame + let oldInspectorFrame = hit.inspectorView.frame + _ = applyHostedInspectorDividerWidth( + preferredWidth, + to: hit, + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: reason + ) + return !Self.rectApproximatelyEqual(oldPageFrame, hit.pageView.frame, epsilon: 0.5) || + !Self.rectApproximatelyEqual(oldInspectorFrame, hit.inspectorView.frame, epsilon: 0.5) + } + + @discardableResult + private func applyHostedInspectorDividerWidth( + _ preferredWidth: CGFloat, + to hit: HostedInspectorDividerHit, + minimumInspectorWidth: CGFloat, + reason: String + ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { + let containerBounds = hit.containerView.bounds + let nextFrames = hit.dockSide.resizedFrames( + preferredWidth: preferredWidth, + in: containerBounds, + pageFrame: hit.pageView.frame, + inspectorFrame: hit.inspectorView.frame, + minimumInspectorWidth: minimumInspectorWidth + ) + let pageFrame = nextFrames.pageFrame + let inspectorFrame = nextFrames.inspectorFrame + + let oldPageFrame = hit.pageView.frame + let oldInspectorFrame = hit.inspectorView.frame + let pageChanged = !Self.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5) + let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5) + guard pageChanged || inspectorChanged else { + return (pageFrame, inspectorFrame) + } + + hit.slotView.isApplyingHostedInspectorLayout = true + CATransaction.begin() + CATransaction.setDisableActions(true) + hit.pageView.frame = pageFrame + hit.inspectorView.frame = inspectorFrame + CATransaction.commit() + hit.slotView.isApplyingHostedInspectorLayout = false + + let isLiveDrag = reason == "drag" + hit.pageView.needsDisplay = true + hit.pageView.setNeedsDisplay(hit.pageView.bounds) + hit.inspectorView.needsDisplay = true + hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds) + hit.containerView.needsDisplay = true + hit.containerView.setNeedsDisplay(hit.containerView.bounds) + hit.slotView.needsDisplay = true + hit.slotView.setNeedsDisplay(hit.slotView.bounds) +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=reapply slot=\(browserPortalDebugToken(hit.slotView)) " + + "container=\(browserPortalDebugToken(hit.containerView)) reason=\(reason) " + + "preferredWidth=\(String(format: "%.1f", preferredWidth)) " + + "liveDrag=\(isLiveDrag ? 1 : 0) " + + "pageChanged=\(pageChanged ? 1 : 0) inspectorChanged=\(inspectorChanged ? 1 : 0) " + + "oldPageFrame=\(browserPortalDebugFrame(oldPageFrame)) oldInspectorFrame=\(browserPortalDebugFrame(oldInspectorFrame)) " + + "pageFrame=\(browserPortalDebugFrame(pageFrame)) " + + "inspectorFrame=\(browserPortalDebugFrame(inspectorFrame))" + ) +#endif + return (pageFrame, inspectorFrame) + } + private static func dividerHit( + at windowPoint: NSPoint, + in view: NSView, + hostView: WindowBrowserHostView + ) -> DividerHit? { guard !view.isHidden else { return nil } if let splitView = view as? NSSplitView { @@ -250,21 +1092,67 @@ final class WindowBrowserHostView: NSView { } let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion) if expanded.contains(pointInSplit) { - return splitView.isVertical ? .vertical : .horizontal + return DividerHit( + kind: splitView.isVertical ? .vertical : .horizontal, + isInHostedContent: splitView.isDescendant(of: hostView) + ) } } } } for subview in view.subviews.reversed() { - if let kind = dividerCursorKind(at: windowPoint, in: subview) { - return kind + if let hit = dividerHit(at: windowPoint, in: subview, hostView: hostView) { + return hit } } return nil } + private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { + max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) + } + + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } + + private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.01) -> Bool { + abs(lhs.width - rhs.width) <= epsilon && + abs(lhs.height - rhs.height) <= epsilon + } + + private static func visibleDescendants(in root: NSView) -> [NSView] { + var descendants: [NSView] = [] + var stack = Array(root.subviews.reversed()) + while let view = stack.popLast() { + descendants.append(view) + stack.append(contentsOf: view.subviews.reversed()) + } + return descendants + } + + private static func isInspectorView(_ view: NSView) -> Bool { + String(describing: type(of: view)).contains("WKInspector") + } + + private static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool { + !view.isHidden && + view.alphaValue > 0 && + view.frame.width > 1 && + view.frame.height > 1 + } + + private static func isVisibleHostedInspectorSiblingCandidate(_ view: NSView) -> Bool { + !view.isHidden && + view.alphaValue > 0 && + view.frame.height > 1 + } + private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) { guard !view.isHidden else { return } @@ -302,8 +1190,412 @@ final class WindowBrowserHostView: NSView { } +private final class BrowserDropZoneOverlayView: NSView { + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + +struct BrowserPortalSearchOverlayConfiguration { + let panelId: UUID + let searchState: BrowserSearchState + let focusRequestGeneration: UInt64 + let canApplyFocusRequest: (UInt64) -> Bool + let onNext: () -> Void + let onPrevious: () -> Void + let onClose: () -> Void + let onFieldDidFocus: () -> Void +} + +struct BrowserPaneDropContext: Equatable { + let workspaceId: UUID + let panelId: UUID + let paneId: PaneID +} + +struct BrowserPaneDragTransfer: Equatable { + let tabId: UUID + let sourcePaneId: UUID + let sourceProcessId: Int32 + + var isFromCurrentProcess: Bool { + sourceProcessId == Int32(ProcessInfo.processInfo.processIdentifier) + } + + static func decode(from pasteboard: NSPasteboard) -> BrowserPaneDragTransfer? { + if let data = pasteboard.data(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) { + return decode(from: data) + } + if let raw = pasteboard.string(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) { + return decode(from: Data(raw.utf8)) + } + return nil + } + + static func decode(from data: Data) -> BrowserPaneDragTransfer? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tab = json["tab"] as? [String: Any], + let tabIdRaw = tab["id"] as? String, + let tabId = UUID(uuidString: tabIdRaw), + let sourcePaneIdRaw = json["sourcePaneId"] as? String, + let sourcePaneId = UUID(uuidString: sourcePaneIdRaw) else { + return nil + } + + let sourceProcessId = (json["sourceProcessId"] as? NSNumber)?.int32Value ?? -1 + return BrowserPaneDragTransfer( + tabId: tabId, + sourcePaneId: sourcePaneId, + sourceProcessId: sourceProcessId + ) + } +} + +struct BrowserPaneSplitTarget: Equatable { + let orientation: SplitOrientation + let insertFirst: Bool +} + +enum BrowserPaneDropAction: Equatable { + case noOp + case move( + tabId: UUID, + targetWorkspaceId: UUID, + targetPane: PaneID, + splitTarget: BrowserPaneSplitTarget? + ) +} + +enum BrowserPaneDropRouting { + private static let padding: CGFloat = 4 + + private static func fullPaneSize(for slotSize: CGSize, topChromeHeight: CGFloat) -> CGSize { + CGSize(width: slotSize.width, height: slotSize.height + max(0, topChromeHeight)) + } + + static func zone(for location: CGPoint, in size: CGSize, topChromeHeight: CGFloat = 0) -> DropZone { + let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight) + let edgeRatio: CGFloat = 0.25 + let horizontalEdge = max(80, fullPaneSize.width * edgeRatio) + let verticalEdge = max(80, fullPaneSize.height * edgeRatio) + + if location.x < horizontalEdge { + return .left + } else if location.x > fullPaneSize.width - horizontalEdge { + return .right + } else if location.y > fullPaneSize.height - verticalEdge { + return .top + } else if location.y < verticalEdge { + return .bottom + } else { + return .center + } + } + + static func overlayFrame(for zone: DropZone, in size: CGSize, topChromeHeight: CGFloat = 0) -> CGRect { + let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight) + switch zone { + case .center: + return CGRect( + x: padding, + y: padding, + width: fullPaneSize.width - padding * 2, + height: fullPaneSize.height - padding * 2 + ) + case .left: + return CGRect( + x: padding, + y: padding, + width: fullPaneSize.width / 2 - padding, + height: fullPaneSize.height - padding * 2 + ) + case .right: + return CGRect( + x: fullPaneSize.width / 2, + y: padding, + width: fullPaneSize.width / 2 - padding, + height: fullPaneSize.height - padding * 2 + ) + case .top: + return CGRect( + x: padding, + y: fullPaneSize.height / 2, + width: fullPaneSize.width - padding * 2, + height: fullPaneSize.height / 2 - padding + ) + case .bottom: + return CGRect( + x: padding, + y: padding, + width: fullPaneSize.width - padding * 2, + height: fullPaneSize.height / 2 - padding + ) + } + } + + static func action( + for transfer: BrowserPaneDragTransfer, + target: BrowserPaneDropContext, + zone: DropZone + ) -> BrowserPaneDropAction? { + if zone == .center, transfer.sourcePaneId == target.paneId.id { + return .noOp + } + + let splitTarget: BrowserPaneSplitTarget? + switch zone { + case .center: + splitTarget = nil + case .left: + splitTarget = BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: true) + case .right: + splitTarget = BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false) + case .top: + splitTarget = BrowserPaneSplitTarget(orientation: .vertical, insertFirst: true) + case .bottom: + splitTarget = BrowserPaneSplitTarget(orientation: .vertical, insertFirst: false) + } + + return .move( + tabId: transfer.tabId, + targetWorkspaceId: target.workspaceId, + targetPane: target.paneId, + splitTarget: splitTarget + ) + } +} + +final class BrowserPaneDropTargetView: NSView { + weak var slotView: WindowBrowserSlotView? + var dropContext: BrowserPaneDropContext? + private var activeZone: DropZone? +#if DEBUG + private var lastHitTestSignature: String? +#endif + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([DragOverlayRoutingPolicy.bonsplitTabTransferType]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + static func shouldCaptureHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) else { return false } + guard let eventType else { return false } + + switch eventType { + case .cursorUpdate, + .mouseEntered, + .mouseExited, + .mouseMoved, + .leftMouseDragged, + .rightMouseDragged, + .otherMouseDragged, + .appKitDefined, + .applicationDefined, + .systemDefined, + .periodic: + return true + default: + return false + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point), dropContext != nil else { return nil } + + let pasteboardTypes = NSPasteboard(name: .drag).types + let eventType = NSApp.currentEvent?.type + let capture = Self.shouldCaptureHitTesting( + pasteboardTypes: pasteboardTypes, + eventType: eventType + ) +#if DEBUG + logHitTestDecision(capture: capture, pasteboardTypes: pasteboardTypes, eventType: eventType) +#endif + return capture ? self : nil + } + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDragState(sender, phase: "entered") + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDragState(sender, phase: "updated") + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + clearDragState(phase: "exited") + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + defer { + clearDragState(phase: "perform.clear") + } + + guard let dropContext, + let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess else { +#if DEBUG + dlog("browser.paneDrop.perform allowed=0 reason=missingTransfer") +#endif + return false + } + + let location = convert(sender.draggingLocation, from: nil) + let zone = BrowserPaneDropRouting.zone( + for: location, + in: bounds.size, + topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0 + ) + guard let action = BrowserPaneDropRouting.action( + for: transfer, + target: dropContext, + zone: zone + ) else { +#if DEBUG + dlog( + "browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "reason=noAction zone=\(zone)" + ) +#endif + return false + } + + switch action { + case .noOp: +#if DEBUG + dlog( + "browser.paneDrop.perform allowed=1 panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(transfer.tabId.uuidString.prefix(5)) action=noop" + ) +#endif + return true + case .move(let tabId, let workspaceId, let targetPane, let splitTarget): + let moved = AppDelegate.shared?.moveBonsplitTab( + tabId: tabId, + toWorkspace: workspaceId, + targetPane: targetPane, + splitTarget: splitTarget.map { ($0.orientation, $0.insertFirst) }, + focus: true, + focusWindow: true + ) ?? false +#if DEBUG + let splitLabel = splitTarget.map { + "\($0.orientation.rawValue):\($0.insertFirst ? 1 : 0)" + } ?? "none" + dlog( + "browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuidString.prefix(5)) zone=\(zone) pane=\(targetPane.id.uuidString.prefix(5)) " + + "split=\(splitLabel) moved=\(moved ? 1 : 0)" + ) +#endif + return moved + } + } + + private func updateDragState(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + guard let dropContext, + let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess else { + clearDragState(phase: "\(phase).reject") + return [] + } + + let location = convert(sender.draggingLocation, from: nil) + let zone = BrowserPaneDropRouting.zone( + for: location, + in: bounds.size, + topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0 + ) + activeZone = zone + slotView?.setPortalDragDropZone(zone) +#if DEBUG + dlog( + "browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)" + ) +#endif + return .move + } + + private func clearDragState(phase: String) { + guard activeZone != nil else { return } + activeZone = nil + slotView?.setPortalDragDropZone(nil) +#if DEBUG + if let dropContext { + dlog( + "browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) zone=none" + ) + } +#endif + } + +#if DEBUG + private func logHitTestDecision( + capture: Bool, + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) { + let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + guard hasTransferType || capture else { return } + + let signature = [ + capture ? "1" : "0", + hasTransferType ? "1" : "0", + String(describing: dropContext != nil), + eventType.map { String($0.rawValue) } ?? "nil", + ].joined(separator: "|") + guard lastHitTestSignature != signature else { return } + lastHitTestSignature = signature + + let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-" + dlog( + "browser.paneDrop.hitTest capture=\(capture ? 1 : 0) " + + "hasTransfer=\(hasTransferType ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " + + "event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)" + ) + } +#endif +} + final class WindowBrowserSlotView: NSView { override var isOpaque: Bool { false } + override var isHidden: Bool { + didSet { + guard isHidden, !oldValue, let window else { return } + yieldOwnedFirstResponderIfNeeded(in: window, reason: "slotHidden") + } + } + private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero) + private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero) + private var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>? + private weak var hostedWebView: WKWebView? + private var hostedWebViewConstraints: [NSLayoutConstraint] = [] + private var forwardedDropZone: DropZone? + private var portalDragDropZone: DropZone? + private var displayedDropZone: DropZone? + private var dropZoneOverlayAnimationGeneration: UInt64 = 0 + private var isRefreshingInteractionLayers = false + private var paneTopChromeHeight: CGFloat = 0 + var preferredHostedInspectorWidth: CGFloat? + private var preferredHostedInspectorWidthFraction: CGFloat? + fileprivate var isHostedInspectorDividerDragActive = false + var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)? + fileprivate var isApplyingHostedInspectorLayout = false + private var lastHostedInspectorLayoutBoundsSize: NSSize? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -311,21 +1603,438 @@ final class WindowBrowserSlotView: NSView { layer?.masksToBounds = true translatesAutoresizingMaskIntoConstraints = true autoresizingMask = [] + + paneDropTargetView.slotView = self + + dropZoneOverlayView.wantsLayer = true + dropZoneOverlayView.layer?.backgroundColor = cmuxAccentNSColor().withAlphaComponent(0.25).cgColor + dropZoneOverlayView.layer?.borderColor = cmuxAccentNSColor().cgColor + dropZoneOverlayView.layer?.borderWidth = 2 + dropZoneOverlayView.layer?.cornerRadius = 8 + dropZoneOverlayView.isHidden = true + addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) } @available(*, unavailable) required init?(coder: NSCoder) { nil } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil, let currentWindow = window { + yieldOwnedFirstResponderIfNeeded(in: currentWindow, reason: "slotWillLeaveWindow") + } + super.viewWillMove(toWindow: newWindow) + } + + override func layout() { + super.layout() + paneDropTargetView.frame = bounds + applyResolvedDropZoneOverlay() + guard !isApplyingHostedInspectorLayout else { return } + if let previousSize = lastHostedInspectorLayoutBoundsSize, + Self.sizeApproximatelyEqual(previousSize, bounds.size) { + return + } + lastHostedInspectorLayoutBoundsSize = bounds.size + onHostedInspectorLayout?(self) + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + attachDropZoneOverlayIfNeeded() + applyResolvedDropZoneOverlay() + } + + func recordPreferredHostedInspectorWidth(_ width: CGFloat, containerBounds: NSRect) { + preferredHostedInspectorWidth = width + guard containerBounds.width > 0 else { + preferredHostedInspectorWidthFraction = nil + return + } + preferredHostedInspectorWidthFraction = width / containerBounds.width + } + + func resolvedPreferredHostedInspectorWidth(in containerBounds: NSRect) -> CGFloat? { + if let preferredHostedInspectorWidthFraction, containerBounds.width > 0 { + return max(0, containerBounds.width * preferredHostedInspectorWidthFraction) + } + return preferredHostedInspectorWidth + } + + private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.width - rhs.width) <= epsilon && + abs(lhs.height - rhs.height) <= epsilon + } + + func setDropZoneOverlay(zone: DropZone?) { + forwardedDropZone = zone + applyResolvedDropZoneOverlay() + } + + func setPortalDragDropZone(_ zone: DropZone?) { + portalDragDropZone = zone + applyResolvedDropZoneOverlay() + } + + func setPaneDropContext(_ context: BrowserPaneDropContext?) { + paneDropTargetView.dropContext = context + } + + func setPaneTopChromeHeight(_ height: CGFloat) { + let resolvedHeight = max(0, height) + guard abs(paneTopChromeHeight - resolvedHeight) > 0.5 else { return } + paneTopChromeHeight = resolvedHeight + applyResolvedDropZoneOverlay() + } + + private func logSearchOverlayEvent(_ action: String, panelId: UUID?) { +#if DEBUG + let firstResponderSummary: String = { + guard let firstResponder = window?.firstResponder else { return "nil" } + if let editor = firstResponder as? NSTextView, editor.isFieldEditor { + let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil" + return "fieldEditor(delegate=\(delegateSummary))" + } + return String(describing: type(of: firstResponder)) + }() + dlog( + "browser.findbar.portal action=\(action) " + + "panel=\(panelId?.uuidString.prefix(5) ?? "nil") " + + "window=\(window?.windowNumber ?? -1) " + + "firstResponder=\(firstResponderSummary) " + + "hasOverlay=\(searchOverlayHostingView != nil ? 1 : 0)" + ) +#endif + } + + func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) { + guard let configuration else { + logSearchOverlayEvent("remove", panelId: nil) + if let overlay = searchOverlayHostingView { + objc_setAssociatedObject( + overlay, + &cmuxBrowserSearchOverlayPanelIdAssociationKey, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + searchOverlayHostingView?.removeFromSuperview() + searchOverlayHostingView = nil + return + } + + logSearchOverlayEvent("set", panelId: configuration.panelId) + let rootView = BrowserSearchOverlay( + panelId: configuration.panelId, + searchState: configuration.searchState, + focusRequestGeneration: configuration.focusRequestGeneration, + canApplyFocusRequest: configuration.canApplyFocusRequest, + onNext: configuration.onNext, + onPrevious: configuration.onPrevious, + onClose: configuration.onClose, + onFieldDidFocus: configuration.onFieldDidFocus + ) + + if let overlay = searchOverlayHostingView { + logSearchOverlayEvent("updateExisting", panelId: configuration.panelId) + overlay.rootView = rootView + objc_setAssociatedObject( + overlay, + &cmuxBrowserSearchOverlayPanelIdAssociationKey, + configuration.panelId, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + if overlay.superview !== self { + overlay.removeFromSuperview() + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + return + } + + let overlay = NSHostingView(rootView: rootView) + overlay.translatesAutoresizingMaskIntoConstraints = false + objc_setAssociatedObject( + overlay, + &cmuxBrowserSearchOverlayPanelIdAssociationKey, + configuration.panelId, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + searchOverlayHostingView = overlay + logSearchOverlayEvent("create", panelId: configuration.panelId) + } + + func searchOverlayPanelId(for responder: NSResponder) -> UUID? { + guard let overlay = searchOverlayHostingView, + let view = responder.browserPortalOwningView, + view.isDescendant(of: overlay) else { + return nil + } + return objc_getAssociatedObject(overlay, &cmuxBrowserSearchOverlayPanelIdAssociationKey) as? UUID + } + + @discardableResult + func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool { + guard let firstResponder = window.firstResponder, + searchOverlayPanelId(for: firstResponder) == panelId else { + return false + } + return window.makeFirstResponder(nil) + } + + @discardableResult + private func yieldOwnedFirstResponderIfNeeded(in window: NSWindow, reason: String) -> Bool { + guard let firstResponder = window.firstResponder, + let owningView = firstResponder.browserPortalOwningView, + owningView === self || owningView.isDescendant(of: self) else { + return false + } +#if DEBUG + dlog( + "browser.slot.firstResponder.yield reason=\(reason) " + + "slot=\(browserPortalDebugToken(self)) " + + "responder=\(String(describing: type(of: firstResponder)))" + ) +#endif + return window.makeFirstResponder(nil) + } + + func pinHostedWebView(_ webView: WKWebView) { + guard webView.superview === self else { return } + + let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) + let needsPlainWebViewFrameReset = + !hasCompanionWKSubviews && + Self.frameDiffersFromBounds(webView.frame, bounds: bounds) + let needsFrameHosting = + hostedWebView !== webView || + !hostedWebViewConstraints.isEmpty || + needsPlainWebViewFrameReset || + !webView.translatesAutoresizingMaskIntoConstraints || + webView.autoresizingMask != [.width, .height] + guard needsFrameHosting else { + needsLayout = true + layoutSubtreeIfNeeded() + return + } + + NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebViewConstraints = [] + hostedWebView = webView + // Attached Web Inspector mutates the moved WKWebView's frame directly. + // Re-pin plain web views after cross-host reattach, but preserve the + // WebKit-managed split frame when docked DevTools siblings are present. + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + if !hasCompanionWKSubviews { + webView.frame = bounds + } + needsLayout = true + layoutSubtreeIfNeeded() + } + + private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(frame.minX - bounds.minX) > epsilon || + abs(frame.minY - bounds.minY) > epsilon || + abs(frame.width - bounds.width) > epsilon || + abs(frame.height - bounds.height) > epsilon + } + + private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool { + var stack = host.subviews.filter { $0 !== primaryWebView } + while let current = stack.popLast() { + if current.isDescendant(of: primaryWebView) { + continue + } + if String(describing: type(of: current)).contains("WK") { + return true + } + stack.append(contentsOf: current.subviews) + } + return false + } + + func effectivePaneTopChromeHeight() -> CGFloat { + paneTopChromeHeight + } + + override func didAddSubview(_ subview: NSView) { + super.didAddSubview(subview) + guard subview !== paneDropTargetView else { return } + bringInteractionLayersToFrontIfNeeded() + } + + private var activeDropZone: DropZone? { + portalDragDropZone ?? forwardedDropZone + } + + private func overlayContainerView() -> NSView { + superview ?? self + } + + private func attachDropZoneOverlayIfNeeded() { + let container = overlayContainerView() + guard dropZoneOverlayView.superview !== container else { return } + dropZoneOverlayView.removeFromSuperview() + container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) + } + + private func applyResolvedDropZoneOverlay() { + let resolvedZone = activeDropZone + if resolvedZone != nil, (bounds.width <= 2 || bounds.height <= 2) { + bringInteractionLayersToFrontIfNeeded() + return + } + + let previousZone = displayedDropZone + displayedDropZone = resolvedZone + let previousFrame = dropZoneOverlayView.frame + + guard let zone = resolvedZone else { + guard !dropZoneOverlayView.isHidden else { + bringInteractionLayersToFrontIfNeeded() + return + } + + dropZoneOverlayAnimationGeneration &+= 1 + let animationGeneration = dropZoneOverlayAnimationGeneration + dropZoneOverlayView.layer?.removeAllAnimations() + bringInteractionLayersToFrontIfNeeded() + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + dropZoneOverlayView.animator().alphaValue = 0 + } completionHandler: { [weak self] in + guard let self else { return } + guard self.dropZoneOverlayAnimationGeneration == animationGeneration else { return } + guard self.displayedDropZone == nil else { return } + self.dropZoneOverlayView.isHidden = true + self.dropZoneOverlayView.alphaValue = 1 + } + return + } + attachDropZoneOverlayIfNeeded() + + let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size) + let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame) + let zoneChanged = previousZone != zone + + if !dropZoneOverlayView.isHidden && !needsFrameUpdate && !zoneChanged { + bringInteractionLayersToFrontIfNeeded() + return + } + + dropZoneOverlayAnimationGeneration &+= 1 + dropZoneOverlayView.layer?.removeAllAnimations() + + if dropZoneOverlayView.isHidden { + applyDropZoneOverlayFrame(targetFrame) + dropZoneOverlayView.alphaValue = 0 + dropZoneOverlayView.isHidden = false + bringInteractionLayersToFrontIfNeeded() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + dropZoneOverlayView.animator().alphaValue = 1 + } + return + } + + bringInteractionLayersToFrontIfNeeded() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + if needsFrameUpdate { + dropZoneOverlayView.animator().frame = targetFrame + } + if dropZoneOverlayView.alphaValue < 1 { + dropZoneOverlayView.animator().alphaValue = 1 + } + } + } + + private func interactionLayerPriority(of view: NSView) -> Int { + if view === paneDropTargetView { return 1 } + return 0 + } + + private func bringInteractionLayersToFrontIfNeeded() { + guard !isRefreshingInteractionLayers else { return } + isRefreshingInteractionLayers = true + defer { isRefreshingInteractionLayers = false } + + if paneDropTargetView.superview !== self { + addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) + } + let overlayContainer = overlayContainerView() + if dropZoneOverlayView.superview !== overlayContainer { + attachDropZoneOverlayIfNeeded() + } else if overlayContainer.subviews.last !== dropZoneOverlayView { + overlayContainer.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) + } + + let context = Unmanaged.passUnretained(self).toOpaque() + sortSubviews({ lhs, rhs, context in + guard let context else { return .orderedSame } + let slotView = Unmanaged<WindowBrowserSlotView>.fromOpaque(context).takeUnretainedValue() + let lhsPriority = slotView.interactionLayerPriority(of: lhs) + let rhsPriority = slotView.interactionLayerPriority(of: rhs) + if lhsPriority == rhsPriority { return .orderedSame } + return lhsPriority < rhsPriority ? .orderedAscending : .orderedDescending + }, context: context) + } + + private func applyDropZoneOverlayFrame(_ frame: CGRect) { + if Self.rectApproximatelyEqual(dropZoneOverlayView.frame, frame) { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + dropZoneOverlayView.frame = frame + CATransaction.commit() + } + + private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { + let localFrame = BrowserPaneDropRouting.overlayFrame( + for: zone, + in: size, + topChromeHeight: paneTopChromeHeight + ) + guard let superview else { return localFrame } + return superview.convert(localFrame, from: self) + } + + private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } } @MainActor final class WindowBrowserPortal: NSObject { + private static let transientRecoveryRetryBudget: Int = 12 + private weak var window: NSWindow? private let hostView = WindowBrowserHostView(frame: .zero) private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] private struct Entry { weak var webView: WKWebView? @@ -333,6 +2042,12 @@ final class WindowBrowserPortal: NSObject { weak var anchorView: NSView? var visibleInUI: Bool var zPriority: Int + var dropZone: DropZone? + var paneDropContext: BrowserPaneDropContext? + var searchOverlay: BrowserPortalSearchOverlayConfiguration? + var paneTopChromeHeight: CGFloat + var transientRecoveryReason: String? + var transientRecoveryRetriesRemaining: Int } private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:] @@ -345,23 +2060,121 @@ final class WindowBrowserPortal: NSObject { hostView.layer?.masksToBounds = true hostView.translatesAutoresizingMaskIntoConstraints = true hostView.autoresizingMask = [] + installGeometryObservers(for: window) _ = ensureInstalled() } + static func shouldTreatSplitResizeAsExternalGeometry( + _ splitView: NSSplitView, + window: NSWindow, + hostView: WindowBrowserHostView + ) -> Bool { + guard splitView.window === window else { return false } + // WebKit's attached DevTools uses internal NSSplitView instances for the + // side/bottom inspector layout. Those resizes are local to hosted content + // and should not trigger a full portal re-sync/refresh pass. + return !splitView.isDescendant(of: hostView) + } + + 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, + Self.shouldTreatSplitResizeAsExternalGeometry( + splitView, + window: window, + hostView: self.hostView + ) 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") + + for entry in entriesByWebViewId.values { + guard let webView = entry.webView, + let containerView = entry.containerView, + !containerView.isHidden else { continue } + guard webView.superview === containerView else { continue } + invalidateHostedWebViewGeometry( + webView, + in: containerView, + reason: "externalGeometry" + ) + } + } + @discardableResult private func ensureInstalled() -> Bool { guard let window else { return false } guard let (container, reference) = installationTarget(for: window) else { return false } + let placementReference = preferredHostPlacementReference(in: container, fallback: reference) if hostView.superview !== container || installedContainerView !== container || installedReferenceView !== reference { hostView.removeFromSuperview() - container.addSubview(hostView, positioned: .above, relativeTo: reference) + container.addSubview(hostView, positioned: .above, relativeTo: placementReference) installedContainerView = container installedReferenceView = reference - } else if !Self.isView(hostView, above: reference, in: container) { - container.addSubview(hostView, positioned: .above, relativeTo: reference) + } else { + let aboveReference = Self.isView(hostView, above: reference, in: container) + let abovePlacementReference = placementReference === reference + || Self.isView(hostView, above: placementReference, in: container) + if !aboveReference || !abovePlacementReference { + container.addSubview(hostView, positioned: .above, relativeTo: placementReference) + } } synchronizeHostFrameToReference() @@ -419,13 +2232,72 @@ 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 searchOverlayConfigurationsEquivalent( + _ lhs: BrowserPortalSearchOverlayConfiguration?, + _ rhs: BrowserPortalSearchOverlayConfiguration? + ) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (lhs?, rhs?): + return lhs.panelId == rhs.panelId && + lhs.searchState === rhs.searchState && + lhs.focusRequestGeneration == rhs.focusRequestGeneration + default: + return false + } + } + + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. + /// SwiftUI/AppKit hosting layers can briefly report an anchor bounds rect larger than the + /// visible split pane during rearrangement; intersecting through ancestor bounds keeps the + /// portal locked to the pane the user can actually see. + private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect { + var frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + var current = anchorView.superview + while let ancestor = current { + let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil) + let finiteAncestorBounds = + ancestorBoundsInWindow.origin.x.isFinite && + ancestorBoundsInWindow.origin.y.isFinite && + ancestorBoundsInWindow.size.width.isFinite && + ancestorBoundsInWindow.size.height.isFinite + if finiteAncestorBounds { + frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow) + if frameInWindow.isNull { return .zero } + } + if ancestor === installedReferenceView { break } + current = ancestor.superview + } + return frameInWindow + } + private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { frame.minX < bounds.minX - epsilon || frame.minY < bounds.minY - epsilon || @@ -433,6 +2305,71 @@ final class WindowBrowserPortal: NSObject { frame.maxY > bounds.maxY + epsilon } + private static func hasVisibleInspectorDescendant(in root: NSView) -> Bool { + var stack: [NSView] = [root] + while let current = stack.popLast() { + if current !== root { + let className = String(describing: type(of: current)) + if className.contains("WKInspector"), + !current.isHidden, + current.alphaValue > 0, + current.frame.width > 1, + current.frame.height > 1 { + return true + } + } + stack.append(contentsOf: current.subviews) + } + return false + } + + private static func inferredBottomDockedInspectorFrame( + in containerView: NSView, + primaryWebView: WKWebView, + epsilon: CGFloat = 1 + ) -> NSRect? { + let pageFrame = primaryWebView.frame + let containerBounds = containerView.bounds + + let candidates = containerView.subviews.compactMap { candidate -> NSRect? in + guard candidate !== primaryWebView else { return nil } + guard hasVisibleInspectorDescendant(in: candidate) else { return nil } + + let frame = candidate.frame + guard frame.width > 1, frame.height > 1 else { return nil } + let overlapWidth = min(pageFrame.maxX, frame.maxX) - max(pageFrame.minX, frame.minX) + guard overlapWidth > min(pageFrame.width, frame.width) * 0.7 else { return nil } + guard frame.minY <= containerBounds.minY + epsilon else { return nil } + guard frame.maxY <= pageFrame.minY + epsilon else { return nil } + return frame + } + + return candidates.max(by: { $0.height < $1.height }) + } + + private static func repairedBottomDockedPageFrame( + in containerView: NSView, + primaryWebView: WKWebView, + epsilon: CGFloat = 0.5 + ) -> NSRect? { + let pageFrame = primaryWebView.frame + let containerBounds = containerView.bounds + guard frameExtendsOutsideBounds(pageFrame, bounds: containerBounds, epsilon: epsilon), + let inspectorFrame = inferredBottomDockedInspectorFrame( + in: containerView, + primaryWebView: primaryWebView + ) else { + return nil + } + + return NSRect( + x: containerBounds.minX, + y: inspectorFrame.maxY, + width: containerBounds.width, + height: max(0, containerBounds.maxY - inspectorFrame.maxY) + ) + } + #if DEBUG private static func inspectorSubviewCount(in root: NSView) -> Int { var stack: [NSView] = [root] @@ -457,11 +2394,23 @@ final class WindowBrowserPortal: NSObject { return viewIndex > referenceIndex } + private func preferredHostPlacementReference(in container: NSView, fallback reference: NSView) -> NSView { + container.subviews.last(where: { + $0 !== hostView && ($0 === reference || $0 is WindowTerminalHostView) + }) ?? reference + } + private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView { if let existing = entry.containerView { + existing.setPaneDropContext(entry.paneDropContext) + existing.setSearchOverlay(entry.searchOverlay) + existing.setPaneTopChromeHeight(entry.paneTopChromeHeight) return existing } let created = WindowBrowserSlotView(frame: .zero) + created.setPaneDropContext(entry.paneDropContext) + created.setSearchOverlay(entry.searchOverlay) + created.setPaneTopChromeHeight(entry.paneTopChromeHeight) #if DEBUG dlog( "browser.portal.container.create web=\(browserPortalDebugToken(webView)) " + @@ -471,6 +2420,147 @@ final class WindowBrowserPortal: NSObject { return created } + private func runHostedWebViewRefreshPass( + _ webView: WKWebView, + in containerView: WindowBrowserSlotView, + reason: String, + phase: String, + reattachRenderingState: Bool + ) { + guard !containerView.isHidden else { return } + guard !containerView.isHostedInspectorDividerDragActive else { +#if DEBUG + dlog( + "browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) reason=\(reason) phase=\(phase) " + + "drag=1 reattach=\(reattachRenderingState ? 1 : 0)" + ) +#endif + return + } + + containerView.needsLayout = true + containerView.needsDisplay = true + containerView.setNeedsDisplay(containerView.bounds) + + if let scrollView = webView.enclosingScrollView { + scrollView.needsLayout = true + scrollView.needsDisplay = true + scrollView.setNeedsDisplay(scrollView.bounds) + scrollView.contentView.needsLayout = true + scrollView.contentView.needsDisplay = true + } + + webView.needsLayout = true + webView.needsDisplay = true + webView.setNeedsDisplay(webView.bounds) + + containerView.layoutSubtreeIfNeeded() + if let scrollView = webView.enclosingScrollView { + scrollView.layoutSubtreeIfNeeded() + scrollView.contentView.layoutSubtreeIfNeeded() + scrollView.displayIfNeeded() + } + webView.layoutSubtreeIfNeeded() + if reattachRenderingState { + webView.browserPortalReattachRenderingState(reason: "\(reason):\(phase)") + } + containerView.displayIfNeeded() + webView.displayIfNeeded() + (webView.window ?? hostView.window)?.displayIfNeeded() +#if DEBUG + dlog( + "\(reattachRenderingState ? "browser.portal.refresh" : "browser.portal.invalidate") " + + "web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) reason=\(reason) " + + "phase=\(phase) frame=\(browserPortalDebugFrame(containerView.frame))" + ) +#endif + } + + private func invalidateHostedWebViewGeometry( + _ webView: WKWebView, + in containerView: WindowBrowserSlotView, + reason: String + ) { + runHostedWebViewRefreshPass( + webView, + in: containerView, + reason: reason, + phase: "geometry", + reattachRenderingState: false + ) + } + + private func refreshHostedWebViewPresentation( + _ webView: WKWebView, + in containerView: WindowBrowserSlotView, + reason: String + ) { + guard !containerView.isHidden else { return } + + runHostedWebViewRefreshPass( + webView, + in: containerView, + reason: reason, + phase: "immediate", + reattachRenderingState: true + ) + DispatchQueue.main.async { [weak self, weak webView, weak containerView] in + guard let self, let webView, let containerView else { return } + self.runHostedWebViewRefreshPass( + webView, + in: containerView, + reason: reason, + phase: "async", + reattachRenderingState: true + ) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self, weak webView, weak containerView] in + guard let self, let webView, let containerView else { return } + self.runHostedWebViewRefreshPass( + webView, + in: containerView, + reason: reason, + phase: "delayed", + reattachRenderingState: true + ) + } + } + + private enum HostedWebViewPresentationUpdateKind { + case none + case geometryOnly + case refresh + + private static let geometryOnlyReasons: Set<String> = [ + "frame", + "bounds", + "webFrame", + "webFrameBottomDock", + ] + + private static let refreshReasons: Set<String> = [ + "syncAttachContainer", + "syncAttachWebView", + "reveal", + "transientRecovery", + "anchor", + ] + + static func resolve(reasons: [String]) -> Self { + guard !reasons.isEmpty else { return .none } + let reasonSet = Set(reasons) + if !reasonSet.isDisjoint(with: Self.refreshReasons) { + return .refresh + } + if reasonSet.isSubset(of: Self.geometryOnlyReasons) { + return .geometryOnly + } + return .refresh + } + } + private func moveWebKitRelatedSubviewsIfNeeded( from sourceSuperview: NSView, to containerView: WindowBrowserSlotView, @@ -483,7 +2573,12 @@ final class WindowBrowserPortal: NSObject { // UI state does not get orphaned in the old host during split churn. let relatedSubviews = sourceSuperview.subviews.filter { view in if view === primaryWebView { return true } - return String(describing: type(of: view)).contains("WK") + let className = String(describing: type(of: view)) + guard className.contains("WK") else { return false } + if className.contains("WKInspector") { + return !view.isHidden && view.alphaValue > 0 && view.frame.width > 1 && view.frame.height > 1 + } + return true } guard !relatedSubviews.isEmpty else { return } #if DEBUG @@ -527,6 +2622,7 @@ final class WindowBrowserPortal: NSObject { "hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)" ) #endif + entry.webView?.browserPortalNotifyHidden(reason: "detach") entry.webView?.removeFromSuperview() entry.containerView?.removeFromSuperview() } @@ -536,11 +2632,102 @@ final class WindowBrowserPortal: NSObject { /// do not keep an old anchor visible. func updateEntryVisibility(forWebViewId webViewId: ObjectIdentifier, visibleInUI: Bool, zPriority: Int) { guard var entry = entriesByWebViewId[webViewId] else { return } + guard entry.visibleInUI != visibleInUI || entry.zPriority != zPriority else { return } entry.visibleInUI = visibleInUI entry.zPriority = zPriority entriesByWebViewId[webViewId] = entry } + func isWebViewBoundToAnchor(withId webViewId: ObjectIdentifier, anchorView: NSView) -> Bool { + guard let entry = entriesByWebViewId[webViewId], + let boundAnchor = entry.anchorView else { return false } + return boundAnchor === anchorView + } + + func hideWebView(withId webViewId: ObjectIdentifier, source: String = "externalHide") { + guard var entry = entriesByWebViewId[webViewId] else { return } + entry.visibleInUI = false + entry.zPriority = 0 + entriesByWebViewId[webViewId] = entry + synchronizeWebView(withId: webViewId, source: source) + } + + func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) { + guard var entry = entriesByWebViewId[webViewId] else { return } + guard entry.dropZone != zone else { return } + entry.dropZone = zone + entriesByWebViewId[webViewId] = entry + entry.containerView?.setDropZoneOverlay(zone: zone) + } + + func updatePaneDropContext(forWebViewId webViewId: ObjectIdentifier, context: BrowserPaneDropContext?) { + guard var entry = entriesByWebViewId[webViewId] else { return } + guard entry.paneDropContext != context else { return } + entry.paneDropContext = context + entriesByWebViewId[webViewId] = entry + entry.containerView?.setPaneDropContext(context) + } + + func updateSearchOverlay( + forWebViewId webViewId: ObjectIdentifier, + configuration: BrowserPortalSearchOverlayConfiguration? + ) { + guard var entry = entriesByWebViewId[webViewId] else { return } + guard !Self.searchOverlayConfigurationsEquivalent(entry.searchOverlay, configuration) else { return } + entry.searchOverlay = configuration + entriesByWebViewId[webViewId] = entry + entry.containerView?.setSearchOverlay(configuration) + } + + func searchOverlayPanelId(for responder: NSResponder) -> UUID? { + for entry in entriesByWebViewId.values { + if let panelId = entry.containerView?.searchOverlayPanelId(for: responder) { + return panelId + } + } + return nil + } + + @discardableResult + func yieldSearchOverlayFocusIfOwned(by panelId: UUID) -> Bool { + guard let window else { return false } + for entry in entriesByWebViewId.values { + if entry.containerView?.yieldSearchOverlayFocusIfOwned(by: panelId, in: window) == true { + return true + } + } + return false + } + + func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) { + guard var entry = entriesByWebViewId[webViewId] else { return } + let resolvedHeight = max(0, height) + guard abs(entry.paneTopChromeHeight - resolvedHeight) > 0.5 else { return } + entry.paneTopChromeHeight = resolvedHeight + entriesByWebViewId[webViewId] = entry + entry.containerView?.setPaneTopChromeHeight(resolvedHeight) + } + + func forceRefreshWebView(withId webViewId: ObjectIdentifier, reason: String) { + guard ensureInstalled() else { return } + synchronizeWebView( + withId: webViewId, + source: "forceRefresh", + forcePresentationRefresh: true + ) + guard let entry = entriesByWebViewId[webViewId], + let webView = entry.webView, + let containerView = entry.containerView, + !containerView.isHidden else { + return + } + refreshHostedWebViewPresentation( + webView, + in: containerView, + reason: reason + ) + } + func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } @@ -548,7 +2735,19 @@ final class WindowBrowserPortal: NSObject { let anchorId = ObjectIdentifier(anchorView) let previousEntry = entriesByWebViewId[webViewId] let containerView = ensureContainerView( - for: previousEntry ?? Entry(webView: nil, containerView: nil, anchorView: nil, visibleInUI: false, zPriority: 0), + for: previousEntry ?? Entry( + webView: nil, + containerView: nil, + anchorView: nil, + visibleInUI: false, + zPriority: 0, + dropZone: nil, + paneDropContext: nil, + searchOverlay: nil, + paneTopChromeHeight: 0, + transientRecoveryReason: nil, + transientRecoveryRetriesRemaining: 0 + ), webView: webView ) @@ -577,7 +2776,13 @@ final class WindowBrowserPortal: NSObject { containerView: containerView, anchorView: anchorView, visibleInUI: visibleInUI, - zPriority: zPriority + zPriority: zPriority, + dropZone: previousEntry?.dropZone, + paneDropContext: previousEntry?.paneDropContext, + searchOverlay: previousEntry?.searchOverlay, + paneTopChromeHeight: previousEntry?.paneTopChromeHeight ?? 0, + transientRecoveryReason: previousEntry?.transientRecoveryReason, + transientRecoveryRetriesRemaining: previousEntry?.transientRecoveryRetriesRemaining ?? 0 ) let didChangeAnchor: Bool = { @@ -621,11 +2826,11 @@ final class WindowBrowserPortal: NSObject { } else { containerView.addSubview(webView, positioned: .above, relativeTo: nil) } - webView.translatesAutoresizingMaskIntoConstraints = true - webView.autoresizingMask = [.width, .height] - webView.frame = containerView.bounds + containerView.pinHostedWebView(webView) webView.needsLayout = true webView.layoutSubtreeIfNeeded() + } else { + containerView.pinHostedWebView(webView) } if containerView.superview !== hostView { @@ -647,7 +2852,11 @@ final class WindowBrowserPortal: NSObject { hostView.addSubview(containerView, positioned: .above, relativeTo: nil) } - synchronizeWebView(withId: webViewId, source: "bind") + synchronizeWebView( + withId: webViewId, + source: "bind", + forcePresentationRefresh: didChangeAnchor + ) pruneDeadEntries() } @@ -689,9 +2898,54 @@ final class WindowBrowserPortal: NSObject { } } - private func synchronizeWebView(withId webViewId: ObjectIdentifier, source: String) { + private func resetTransientRecoveryRetryIfNeeded(forWebViewId webViewId: ObjectIdentifier, entry: inout Entry) { + guard entry.transientRecoveryRetriesRemaining != 0 || entry.transientRecoveryReason != nil else { return } + entry.transientRecoveryReason = nil + entry.transientRecoveryRetriesRemaining = 0 + entriesByWebViewId[webViewId] = entry + } + + private func scheduleTransientRecoveryRetryIfNeeded( + forWebViewId webViewId: ObjectIdentifier, + entry: inout Entry, + webView: WKWebView, + reason: String + ) -> Bool { + if entry.transientRecoveryReason != reason { + entry.transientRecoveryReason = reason + entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget + } +#if DEBUG + if entry.transientRecoveryRetriesRemaining <= 0 { + dlog( + "browser.portal.sync.deferRecover.skip web=\(browserPortalDebugToken(webView)) " + + "reason=\(reason) exhausted=1" + ) + } +#endif + guard entry.transientRecoveryRetriesRemaining > 0 else { return false } + + entry.transientRecoveryRetriesRemaining -= 1 + entriesByWebViewId[webViewId] = entry +#if DEBUG + dlog( + "browser.portal.sync.deferRecover web=\(browserPortalDebugToken(webView)) " + + "reason=\(reason) remaining=\(entry.transientRecoveryRetriesRemaining)" + ) +#endif + if entry.transientRecoveryRetriesRemaining > 0 { + scheduleDeferredFullSynchronizeAll() + } + return true + } + + private func synchronizeWebView( + withId webViewId: ObjectIdentifier, + source: String, + forcePresentationRefresh: Bool = false + ) { guard ensureInstalled() else { return } - guard let entry = entriesByWebViewId[webViewId] else { return } + guard var entry = entriesByWebViewId[webViewId] else { return } guard let webView = entry.webView else { entriesByWebViewId.removeValue(forKey: webViewId) return @@ -703,7 +2957,62 @@ final class WindowBrowserPortal: NSObject { } return } + let previousTransientRecoveryReason = entry.transientRecoveryReason + func hideContainerView(reason: String) { + containerView.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) + containerView.setPaneDropContext(nil) + containerView.setPortalDragDropZone(nil) + containerView.setDropZoneOverlay(zone: nil) + // Tab/workspace visibility changes should hide the portal slot without forcing + // WebKit through `_exitInWindow`/`_enterInWindow`, which fires visibilitychange + // and can trigger page reloads. Reserve the full lifecycle notify for cases + // where the visible surface is actually leaving the window/render tree. + if entry.visibleInUI, !containerView.isHidden, webView.superview === containerView { + webView.browserPortalNotifyHidden(reason: reason) + } + containerView.isHidden = true + } + func scheduleTransientDetachRecovery(reason: String) -> Bool { + guard entry.visibleInUI else { return false } + return scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: reason + ) + } + func preserveVisibleDuringTransientDetach(reason: String) -> Bool { + guard entry.visibleInUI, !containerView.isHidden else { return false } + let didScheduleTransientRecovery = scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: reason + ) + guard didScheduleTransientRecovery else { return false } +#if DEBUG + dlog( + "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + + "reason=\(reason) frame=\(browserPortalDebugFrame(containerView.frame))" + ) +#endif + containerView.setPaneDropContext(nil) + containerView.setPortalDragDropZone(nil) + containerView.setDropZoneOverlay(zone: nil) + return true + } guard let anchorView = entry.anchorView, let window else { + if preserveVisibleDuringTransientDetach(reason: "missingAnchorOrWindow") { + return + } + if scheduleTransientDetachRecovery(reason: "missingAnchorOrWindow") { + hideContainerView(reason: "missingAnchorOrWindow") + return + } + if !entry.visibleInUI { + resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) + } #if DEBUG if !containerView.isHidden { dlog( @@ -712,10 +3021,30 @@ final class WindowBrowserPortal: NSObject { ) } #endif - containerView.isHidden = true + hideContainerView(reason: "missingAnchorOrWindow") return } guard anchorView.window === window else { + let isOffWindowReparent = + entry.visibleInUI && + anchorView.window == nil && + anchorView.superview != nil + if isOffWindowReparent { + if preserveVisibleDuringTransientDetach(reason: "anchorWindowMismatch.offWindow") { + return + } + if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") { + hideContainerView(reason: "anchorWindowMismatch") + return + } + } + if preserveVisibleDuringTransientDetach(reason: "anchorWindowMismatch") { + return + } + if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") { + hideContainerView(reason: "anchorWindowMismatch") + return + } #if DEBUG if !containerView.isHidden { dlog( @@ -725,10 +3054,14 @@ final class WindowBrowserPortal: NSObject { ) } #endif - containerView.isHidden = true + if !entry.visibleInUI { + resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) + } + hideContainerView(reason: "anchorWindowMismatch") return } + var refreshReasons: [String] = [] if containerView.superview !== hostView { #if DEBUG dlog( @@ -737,8 +3070,20 @@ final class WindowBrowserPortal: NSObject { ) #endif hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + refreshReasons.append("syncAttachContainer") } - if webView.superview !== containerView { + let shouldPreserveExternalHostForHiddenEntry = + !entry.visibleInUI && + webView.superview !== containerView + if shouldPreserveExternalHostForHiddenEntry { +#if DEBUG + dlog( + "browser.portal.reparent.skip web=\(browserPortalDebugToken(webView)) " + + "reason=hiddenEntryExternalHost super=\(browserPortalDebugToken(webView.superview)) " + + "container=\(browserPortalDebugToken(containerView))" + ) +#endif + } else if webView.superview !== containerView { #if DEBUG dlog( "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + @@ -756,16 +3101,16 @@ final class WindowBrowserPortal: NSObject { } else { containerView.addSubview(webView, positioned: .above, relativeTo: nil) } - webView.translatesAutoresizingMaskIntoConstraints = true - webView.autoresizingMask = [.width, .height] - webView.frame = containerView.bounds - webView.needsLayout = true - webView.layoutSubtreeIfNeeded() + containerView.pinHostedWebView(webView) + refreshReasons.append("syncAttachWebView") + } else { + containerView.pinHostedWebView(webView) } _ = synchronizeHostFrameToReference() - let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) let hostBounds = hostView.bounds let hasFiniteHostBounds = hostBounds.origin.x.isFinite && @@ -782,8 +3127,41 @@ final class WindowBrowserPortal: NSObject { "anchor=\(browserPortalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" ) #endif - containerView.isHidden = true - scheduleDeferredFullSynchronizeAll() + if entry.visibleInUI { + let shouldPreserveVisibleOnTransient = !containerView.isHidden && + scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: "hostBoundsNotReady" + ) + if shouldPreserveVisibleOnTransient { +#if DEBUG + dlog( + "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + + "reason=hostBoundsNotReady frame=\(browserPortalDebugFrame(containerView.frame))" + ) +#endif + containerView.setPaneDropContext(nil) + containerView.setPortalDragDropZone(nil) + containerView.setDropZoneOverlay(zone: nil) + return + } + } else { + resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) + } + hideContainerView(reason: "hostBoundsNotReady") + if entry.visibleInUI { + _ = scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: "hostBoundsNotReady" + ) + } else { + scheduleDeferredFullSynchronizeAll() + } + containerView.setPaneTopChromeHeight(0) return } let oldFrame = containerView.frame @@ -807,6 +3185,32 @@ final class WindowBrowserPortal: NSObject { tinyFrame || !hasFiniteFrame || outsideHostBounds + let transientRecoveryReason: String? = { + guard entry.visibleInUI else { return nil } + if anchorHidden { return "anchorHidden" } + if !hasFiniteFrame { return "nonFiniteFrame" } + if outsideHostBounds { return "outsideHostBounds" } + if tinyFrame { return "tinyFrame" } + return nil + }() + let didScheduleTransientRecovery: Bool = { + guard let transientRecoveryReason else { return false } + return scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: transientRecoveryReason + ) + }() + let shouldPreserveVisibleOnTransientGeometry = + didScheduleTransientRecovery && + shouldHide && + entry.visibleInUI && + !containerView.isHidden + let recoveredFromTransientGeometry = + previousTransientRecoveryReason != nil && + transientRecoveryReason == nil && + !shouldHide #if DEBUG let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) if frameWasClamped { @@ -833,11 +3237,32 @@ final class WindowBrowserPortal: NSObject { ) } #endif + if shouldPreserveVisibleOnTransientGeometry { + let hasExistingVisibleFrame = + oldFrame.width > 1 && + oldFrame.height > 1 && + containerView.bounds.width > 1 && + containerView.bounds.height > 1 +#if DEBUG + dlog( + "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + + "reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame)) " + + "keepFrame=\(hasExistingVisibleFrame ? 1 : 0)" + ) +#endif + if hasExistingVisibleFrame { + containerView.setDropZoneOverlay(zone: nil) + containerView.setPaneDropContext(nil) + containerView.setPortalDragDropZone(nil) + return + } + } if !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() CATransaction.setDisableActions(true) containerView.frame = targetFrame CATransaction.commit() + refreshReasons.append("frame") } let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) @@ -854,17 +3279,42 @@ final class WindowBrowserPortal: NSObject { "target=\(browserPortalDebugFrame(expectedContainerBounds))" ) #endif + refreshReasons.append("bounds") } + let containerOwnsWebView = webView.superview === containerView let containerBounds = containerView.bounds - let preNormalizeWebFrame = webView.frame + let preNormalizeWebFrame = containerOwnsWebView ? webView.frame : .zero let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height) let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY) let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow) #if DEBUG let inspectorSubviews = Self.inspectorSubviewCount(in: containerView) #endif - if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) { + if containerOwnsWebView, + let repairedBottomDockFrame = Self.repairedBottomDockedPageFrame( + in: containerView, + primaryWebView: webView + ) { + let oldWebFrame = preNormalizeWebFrame + CATransaction.begin() + CATransaction.setDisableActions(true) + webView.frame = repairedBottomDockFrame + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.webframe.bottomDockRepair web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " + + "new=\(browserPortalDebugFrame(repairedBottomDockFrame)) bounds=\(browserPortalDebugFrame(containerBounds)) " + + "inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " + + "inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " + + "inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " + + "inspectorSubviews=\(inspectorSubviews) " + + "source=\(source)" + ) +#endif + refreshReasons.append("webFrameBottomDock") + } else if containerOwnsWebView && Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) { let oldWebFrame = preNormalizeWebFrame CATransaction.begin() CATransaction.setDisableActions(true) @@ -882,20 +3332,94 @@ final class WindowBrowserPortal: NSObject { "source=\(source)" ) #endif + refreshReasons.append("webFrame") } - if containerView.isHidden != shouldHide { + let revealedForDisplay = !shouldHide && containerView.isHidden + if shouldHide, !containerView.isHidden, !shouldPreserveVisibleOnTransientGeometry { #if DEBUG dlog( "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " + + "host=\(browserPortalDebugFrame(hostBounds))" + ) +#endif + hideContainerView(reason: transientRecoveryReason ?? "geometryHidden") + } else if !shouldHide, containerView.isHidden { +#if DEBUG + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=0 " + + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + "outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " + "host=\(browserPortalDebugFrame(hostBounds))" ) #endif - containerView.isHidden = shouldHide + containerView.isHidden = false + } + containerView.setPaneTopChromeHeight(shouldHide ? 0 : entry.paneTopChromeHeight) + containerView.setSearchOverlay(shouldHide ? nil : entry.searchOverlay) + containerView.setPaneDropContext(containerView.isHidden ? nil : entry.paneDropContext) + containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone) + if revealedForDisplay { + refreshReasons.append("reveal") + } + if recoveredFromTransientGeometry { + // Drag/reparent churn can recover to the same visible frame we preserved. + // Force a redraw so WebKit doesn't keep stale tiles until a later resize/focus. + refreshReasons.append("transientRecovery") + } + if forcePresentationRefresh { + refreshReasons.append("anchor") + } + if transientRecoveryReason == nil { + resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) + } + let hostedInspectorAdjustedDuringSync = + containerOwnsWebView && + hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync") + let presentationUpdateKind = HostedWebViewPresentationUpdateKind.resolve( + reasons: refreshReasons + ) + if !shouldHide, containerOwnsWebView, presentationUpdateKind != .none { + if presentationUpdateKind == .refresh && + hostedInspectorAdjustedDuringSync && + !recoveredFromTransientGeometry { +#if DEBUG + dlog( + "browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) reason=\(source):" + + "\(refreshReasons.joined(separator: ",")) adjustedDuringSync=1" + ) +#endif + } else { + let refreshReason = "\(source):" + refreshReasons.joined(separator: ",") + switch presentationUpdateKind { + case .none: + break + case .geometryOnly: + invalidateHostedWebViewGeometry( + webView, + in: containerView, + reason: refreshReason + ) + case .refresh: + refreshHostedWebViewPresentation( + webView, + in: containerView, + reason: refreshReason + ) + } + } + } + if containerOwnsWebView, !hostedInspectorAdjustedDuringSync { + // Keep the existing post-sync pass for cases where the inspector candidate + // appears only after WebKit settles, but avoid a second apply when sync already clamped it. + _ = hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync.postRefresh") } #if DEBUG dlog( @@ -906,6 +3430,8 @@ final class WindowBrowserPortal: NSObject { "old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " + "target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + "entryVisible=\(entry.visibleInUI ? 1 : 0) " + + "containerOwnsWeb=\(containerOwnsWebView ? 1 : 0) " + + "inspectorAdjusted=\(hostedInspectorAdjustedDuringSync ? 1 : 0) " + "containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " + "containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " + "preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " + @@ -923,16 +3449,24 @@ final class WindowBrowserPortal: NSObject { let deadWebViewIds = entriesByWebViewId.compactMap { webViewId, entry -> ObjectIdentifier? in guard entry.webView != nil else { return webViewId } guard let container = entry.containerView else { return webViewId } - guard let anchor = entry.anchorView else { return webViewId } + guard let anchor = entry.anchorView else { + // Workspace switching hides retiring browser portals before SwiftUI unmounts + // their anchor views. Keep the hidden WKWebView/slot alive so switching back + // can rebind the existing view instead of forcing a full WebKit reload. + return nil + } if container.superview == nil || !container.isDescendant(of: hostView) { return webViewId } - if anchor.window !== currentWindow || anchor.superview == nil { - return webViewId - } - if let reference = installedReferenceView, - !anchor.isDescendant(of: reference) { - return webViewId + let anchorInvalidForCurrentHost = + anchor.window !== currentWindow || + anchor.superview == nil || + (installedReferenceView.map { !anchor.isDescendant(of: $0) } ?? false) + if anchorInvalidForCurrentHost { + // Hidden browser portals can legitimately be off-tree between workspace + // deactivation and the next rebind. Preserve them until an explicit detach + // (panel close, window teardown, or web view replacement) says otherwise. + return nil } return nil } @@ -952,6 +3486,7 @@ final class WindowBrowserPortal: NSObject { } func tearDown() { + removeGeometryObservers() for webViewId in Array(entriesByWebViewId.keys) { detachWebView(withId: webViewId) } @@ -970,6 +3505,19 @@ final class WindowBrowserPortal: NSObject { } #endif + func debugSnapshot(forWebViewId webViewId: ObjectIdentifier) -> BrowserWindowPortalRegistry.DebugSnapshot? { + guard let entry = entriesByWebViewId[webViewId] else { return nil } + let frameInWindow: CGRect = { + guard let container = entry.containerView, container.window != nil else { return .zero } + return container.convert(container.bounds, to: nil) + }() + return BrowserWindowPortalRegistry.DebugSnapshot( + visibleInUI: entry.visibleInUI, + containerHidden: entry.containerView?.isHidden ?? true, + frameInWindow: frameInWindow + ) + } + func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? { guard ensureInstalled() else { return nil } let point = hostView.convert(windowPoint, from: nil) @@ -989,6 +3537,12 @@ final class WindowBrowserPortal: NSObject { @MainActor enum BrowserWindowPortalRegistry { + struct DebugSnapshot { + let visibleInUI: Bool + let containerHidden: Bool + let frameInWindow: CGRect + } + private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] @@ -1086,12 +3640,92 @@ enum BrowserWindowPortalRegistry { portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) } + static func isWebView(_ webView: WKWebView, boundTo anchorView: NSView) -> Bool { + let webViewId = ObjectIdentifier(webView) + guard let window = anchorView.window else { return false } + let windowId = ObjectIdentifier(window) + guard webViewToWindowId[webViewId] == windowId, + let portal = portalsByWindowId[windowId] else { return false } + return portal.isWebViewBoundToAnchor(withId: webViewId, anchorView: anchorView) + } + + static func hide(webView: WKWebView, source: String = "externalHide") { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.hideWebView(withId: webViewId, source: source) + } + + static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updateDropZoneOverlay(forWebViewId: webViewId, zone: zone) + } + + static func updatePaneDropContext(for webView: WKWebView, context: BrowserPaneDropContext?) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updatePaneDropContext(forWebViewId: webViewId, context: context) + } + + static func updateSearchOverlay( + for webView: WKWebView, + configuration: BrowserPortalSearchOverlayConfiguration? + ) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration) + } + + static func searchOverlayPanelId(for responder: NSResponder, in window: NSWindow) -> UUID? { + let windowId = ObjectIdentifier(window) + guard let portal = portalsByWindowId[windowId] else { return nil } + return portal.searchOverlayPanelId(for: responder) + } + + @discardableResult + static func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool { + let windowId = ObjectIdentifier(window) + guard let portal = portalsByWindowId[windowId] else { return false } + return portal.yieldSearchOverlayFocusIfOwned(by: panelId) + } + + static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updatePaneTopChromeHeight(forWebViewId: webViewId, height: height) + } + static func detach(webView: WKWebView) { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } portalsByWindowId[windowId]?.detachWebView(withId: webViewId) } + static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? { + let windowId = ObjectIdentifier(window) + guard let portal = portalsByWindowId[windowId] else { return nil } + return portal.webViewAtWindowPoint(windowPoint) + } + + static func refresh(webView: WKWebView, reason: String) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.forceRefreshWebView(withId: webViewId, reason: reason) + } + + static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return nil } + return portal.debugSnapshot(forWebViewId: webViewId) + } + #if DEBUG static func debugPortalCount() -> Int { portalsByWindowId.count diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4ad00b12..4f3c0725 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,10 +1,121 @@ import AppKit import Bonsplit +import ImageIO import SwiftUI import ObjectiveC import UniformTypeIdentifiers import WebKit +private extension Color { + init?(hex: String) { + let hex = hex.trimmingCharacters(in: .init(charactersIn: "#")) + guard hex.count == 6, let value = UInt64(hex, radix: 16) else { return nil } + self.init( + red: Double((value >> 16) & 0xFF) / 255.0, + green: Double((value >> 8) & 0xFF) / 255.0, + blue: Double( value & 0xFF) / 255.0 + ) + } +} + +private func coloredCircleImage(color: NSColor) -> NSImage { + let size = NSSize(width: 14, height: 14) + let image = NSImage(size: size, flipped: false) { rect in + color.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 1, dy: 1)).fill() + return true + } + image.isTemplate = false + return image +} + +func sidebarActiveForegroundNSColor( + opacity: CGFloat, + appAppearance: NSAppearance? = NSApp?.effectiveAppearance +) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua]) + let baseColor: NSColor = (bestMatch == .darkAqua) ? .white : .black + return baseColor.withAlphaComponent(clampedOpacity) +} + +func cmuxAccentNSColor(for colorScheme: ColorScheme) -> NSColor { + switch colorScheme { + case .dark: + return NSColor( + srgbRed: 0, + green: 145.0 / 255.0, + blue: 1.0, + alpha: 1.0 + ) + default: + return NSColor( + srgbRed: 0, + green: 136.0 / 255.0, + blue: 1.0, + alpha: 1.0 + ) + } +} + +func cmuxAccentNSColor(for appAppearance: NSAppearance?) -> NSColor { + let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua]) + let scheme: ColorScheme = (bestMatch == .darkAqua) ? .dark : .light + return cmuxAccentNSColor(for: scheme) +} + +func cmuxAccentNSColor() -> NSColor { + NSColor(name: nil) { appearance in + cmuxAccentNSColor(for: appearance) + } +} + +func cmuxAccentColor() -> Color { + Color(nsColor: cmuxAccentNSColor()) +} + +func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { + cmuxAccentNSColor(for: colorScheme) +} + +func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + return NSColor.white.withAlphaComponent(clampedOpacity) +} + +#if compiler(>=6.2) +@available(macOS 26.0, *) +enum InternalTabDragConfigurationProvider { + // These drags only make sense inside cmux. Outside the app, Finder should + // reject them instead of materializing placeholder files from the payload. + static let value = DragConfiguration( + operationsWithinApp: .init(allowCopy: false, allowMove: true, allowDelete: false), + operationsOutsideApp: .init(allowCopy: false, allowMove: false, allowDelete: false) + ) +} +#endif + +private struct InternalTabDragConfigurationModifier: ViewModifier { + @ViewBuilder + func body(content: Content) -> some View { + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + content.dragConfiguration(InternalTabDragConfigurationProvider.value) + } else { + content + } + #else + content + #endif + } +} + +extension View { + func internalOnlyTabDrag() -> some View { + modifier(InternalTabDragConfigurationModifier()) + } +} + struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 @@ -151,8 +262,35 @@ enum WindowGlassEffect { } } +/// CALayer-backed titlebar background. Uses layer-level opacity (not per-pixel alpha) +/// to match how the terminal's Metal surface composites its background. +struct TitlebarLayerBackground: NSViewRepresentable { + var backgroundColor: NSColor + var opacity: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + view.layer?.opacity = Float(opacity) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + nsView.layer?.opacity = Float(opacity) + } +} + final class SidebarState: ObservableObject { - @Published var isVisible: Bool = true + @Published var isVisible: Bool + @Published var persistedWidth: CGFloat + + init(isVisible: Bool = true, persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) { + self.isVisible = isVisible + let sanitized = SessionPersistencePolicy.sanitizedSidebarWidth(Double(persistedWidth)) + self.persistedWidth = CGFloat(sanitized) + } func toggle() { isVisible.toggle() @@ -528,7 +666,12 @@ final class FileDropOverlayView: NSView { } /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. - private func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + if let window, + let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { + return portalWebView + } + guard let window, let contentView = window.contentView else { return nil } isHidden = true defer { isHidden = false } @@ -550,9 +693,8 @@ final class FileDropOverlayView: NSView { let themeFrame = contentView.superview else { return "-" } let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) - isHidden = true - defer { isHidden = false } - + // Don't toggle isHidden here — it triggers setNeedsDisplay which can + // exceed AppKit's display-pass limit during cursor-update display cycles. guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } var chain: [String] = [] var current: NSView? = hit @@ -695,6 +837,377 @@ final class FileDropOverlayView: NSView { } var fileDropOverlayKey: UInt8 = 0 +private var commandPaletteWindowOverlayKey: UInt8 = 0 +let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") + +enum CommandPaletteOverlayPromotionPolicy { + static func shouldPromote(previouslyVisible: Bool, isVisible: Bool) -> Bool { + isVisible && !previouslyVisible + } +} + +@MainActor +private final class CommandPaletteOverlayContainerView: NSView { + var capturesMouseEvents = false + + override var isOpaque: Bool { false } + override var acceptsFirstResponder: Bool { true } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard capturesMouseEvents else { return nil } + return super.hitTest(point) + } +} + +@MainActor +private final class WindowCommandPaletteOverlayController: NSObject { + private weak var window: NSWindow? + private let containerView = CommandPaletteOverlayContainerView(frame: .zero) + private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) + private var installConstraints: [NSLayoutConstraint] = [] + private weak var installedThemeFrame: NSView? + private var focusLockTimer: DispatchSourceTimer? + private var scheduledFocusWorkItem: DispatchWorkItem? + private var isPaletteVisible = false + private var windowDidBecomeKeyObserver: NSObjectProtocol? + private var windowDidResignKeyObserver: NSObjectProtocol? + + init(window: NSWindow) { + self.window = window + super.init() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.isHidden = true + containerView.alphaValue = 0 + containerView.capturesMouseEvents = false + containerView.identifier = commandPaletteOverlayContainerIdentifier + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: containerView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ]) + _ = ensureInstalled() + installWindowKeyObservers() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return false } + + if containerView.superview !== themeFrame { + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + containerView.removeFromSuperview() + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + installConstraints = [ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ] + NSLayoutConstraint.activate(installConstraints) + installedThemeFrame = themeFrame + } + + return true + } + + private func promoteOverlayAboveSiblingsIfNeeded() { + guard let themeFrame = installedThemeFrame, + containerView.superview === themeFrame else { return } + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + } + + private func isPaletteResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if let view = responder as? NSView, view.isDescendant(of: containerView) { + return true + } + + if let textView = responder as? NSTextView { + if let delegateView = textView.delegate as? NSView, + delegateView.isDescendant(of: containerView) { + return true + } + } + + return false + } + + private func isPaletteFieldEditor(_ textView: NSTextView) -> Bool { + guard textView.isFieldEditor else { return false } + + if let delegateView = textView.delegate as? NSView, + delegateView.isDescendant(of: containerView) { + return true + } + + // SwiftUI text fields can keep a field editor delegate that isn't an NSView. + // Fall back to validating editor ownership from the mounted palette text field. + if let textField = firstEditableTextField(in: hostingView), + textField.currentEditor() === textView { + return true + } + + return false + } + + private func isPaletteTextInputFirstResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if let textView = responder as? NSTextView { + return isPaletteFieldEditor(textView) + } + + if let textField = responder as? NSTextField { + return textField.isDescendant(of: containerView) + } + + return false + } + + private func firstEditableTextField(in view: NSView) -> NSTextField? { + if let textField = view as? NSTextField, + textField.isEditable, + textField.isEnabled, + !textField.isHiddenOrHasHiddenAncestor { + return textField + } + + for subview in view.subviews { + if let match = firstEditableTextField(in: subview) { + return match + } + } + return nil + } + + private func scheduleFocusIntoPalette(retries: Int = 4) { + scheduledFocusWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.scheduledFocusWorkItem = nil + self?.focusIntoPalette(retries: retries) + } + scheduledFocusWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + } + + private func focusIntoPalette(retries: Int) { + guard let window else { return } + if isPaletteTextInputFirstResponder(window.firstResponder) { + return + } + + if let textField = firstEditableTextField(in: hostingView), + window.makeFirstResponder(textField), + isPaletteTextInputFirstResponder(window.firstResponder) { + normalizeSelectionAfterProgrammaticFocus() + return + } + + if window.makeFirstResponder(containerView) { + if let textField = firstEditableTextField(in: hostingView), + window.makeFirstResponder(textField), + isPaletteTextInputFirstResponder(window.firstResponder) { + normalizeSelectionAfterProgrammaticFocus() + return + } + } + + guard retries > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in + self?.focusIntoPalette(retries: retries - 1) + } + } + + private func installWindowKeyObservers() { + guard let window else { return } + windowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFocusLockForWindowState() + } + } + windowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFocusLockForWindowState() + } + } + } + + private func updateFocusLockForWindowState() { + guard let window else { + stopFocusLockTimer() + return + } + guard isPaletteVisible else { + stopFocusLockTimer() + return + } + + guard window.isKeyWindow else { + stopFocusLockTimer() + if isPaletteResponder(window.firstResponder) { + _ = window.makeFirstResponder(nil) + } + return + } + + startFocusLockTimer() + if !isPaletteTextInputFirstResponder(window.firstResponder) { + scheduleFocusIntoPalette(retries: 8) + } + } + + private func startFocusLockTimer() { + guard focusLockTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(80), leeway: .milliseconds(12)) + timer.setEventHandler { [weak self] in + guard let self else { return } + guard let window = self.window else { + self.stopFocusLockTimer() + return + } + if self.isPaletteTextInputFirstResponder(window.firstResponder) { + return + } + self.focusIntoPalette(retries: 1) + } + focusLockTimer = timer + timer.resume() + } + + private func stopFocusLockTimer() { + focusLockTimer?.cancel() + focusLockTimer = nil + scheduledFocusWorkItem?.cancel() + scheduledFocusWorkItem = nil + } + + private func normalizeSelectionAfterProgrammaticFocus() { + guard let window, + let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor else { return } + + let text = editor.string + let length = (text as NSString).length + let selection = editor.selectedRange() + guard length > 0 else { return } + guard selection.location == 0, selection.length == length else { return } + + // Keep commands-mode prefix semantics stable after focus re-assertions: + // if AppKit selected the entire query (e.g. ">foo"), restore caret-at-end + // so the next keystroke appends instead of replacing and switching modes. + guard text.hasPrefix(">") else { return } + editor.setSelectedRange(NSRange(location: length, length: 0)) + } + + func update(rootView: AnyView, isVisible: Bool) { + guard ensureInstalled() else { return } + let shouldPromote = CommandPaletteOverlayPromotionPolicy.shouldPromote( + previouslyVisible: isPaletteVisible, + isVisible: isVisible + ) + isPaletteVisible = isVisible + if isVisible { + hostingView.rootView = rootView + containerView.capturesMouseEvents = true + containerView.isHidden = false + containerView.alphaValue = 1 + if shouldPromote { + promoteOverlayAboveSiblingsIfNeeded() + } + updateFocusLockForWindowState() + } else { + stopFocusLockTimer() + if let window, isPaletteResponder(window.firstResponder) { + _ = window.makeFirstResponder(nil) + } + hostingView.rootView = AnyView(EmptyView()) + containerView.capturesMouseEvents = false + containerView.alphaValue = 0 + containerView.isHidden = true + } + } + + func underlyingResponder(atWindowPoint windowPoint: NSPoint) -> NSResponder? { + guard let window, + let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + + let previousCapturesMouseEvents = containerView.capturesMouseEvents + containerView.capturesMouseEvents = false + defer { + containerView.capturesMouseEvents = previousCapturesMouseEvents + } + + let pointInTheme = themeFrame.convert(windowPoint, from: nil) + return themeFrame.hitTest(pointInTheme) + } +} + +@MainActor +private func commandPaletteWindowOverlayController(for window: NSWindow) -> WindowCommandPaletteOverlayController { + if let existing = objc_getAssociatedObject(window, &commandPaletteWindowOverlayKey) as? WindowCommandPaletteOverlayController { + return existing + } + let controller = WindowCommandPaletteOverlayController(window: window) + objc_setAssociatedObject(window, &commandPaletteWindowOverlayKey, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return controller +} + +private func commandPaletteOwningWebView(for responder: NSResponder?) -> WKWebView? { + guard let responder else { return nil } + + if let webView = responder as? WKWebView { + return webView + } + + if let view = responder as? NSView { + var current: NSView? = view + while let candidate = current { + if let webView = candidate as? WKWebView { + return webView + } + current = candidate.superview + } + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let webView = commandPaletteOwningWebView(for: delegateView) { + return webView + } + + var currentResponder = responder.nextResponder + while let next = currentResponder { + if let webView = commandPaletteOwningWebView(for: next) { + return webView + } + currentResponder = next.nextResponder + } + + return nil +} enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. @@ -820,17 +1333,308 @@ struct ContentView: View { @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) - @State private var titlebarThemeUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? @State private var sidebarResizerPointerMonitor: Any? @State private var isResizerBandActive = false @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? + @State private var isCommandPalettePresented = false + @State private var commandPaletteQuery: String = "" + @State private var commandPaletteMode: CommandPaletteMode = .commands + @State private var commandPaletteRenameDraft: String = "" + @State private var commandPaletteSelectedResultIndex: Int = 0 + @State private var commandPaletteSelectionAnchorCommandID: String? + @State private var commandPaletteHoveredResultIndex: Int? + @State private var commandPaletteScrollTargetIndex: Int? + @State private var commandPaletteScrollTargetAnchor: UnitPoint? + @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? + @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry<String>] = [] + @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry<String>] = [:] + @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] + @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] + @State private var commandPaletteVisibleResults: [CommandPaletteSearchResult] = [] + @State private var commandPaletteVisibleResultsScope: CommandPaletteListScope? + @State private var commandPaletteVisibleResultsFingerprint: Int? + @State private var cachedCommandPaletteScope: CommandPaletteListScope? + @State private var cachedCommandPaletteFingerprint: Int? + @State private var commandPaletteSearchTask: Task<Void, Never>? + @State private var commandPaletteSearchRequestID: UInt64 = 0 + @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 + @State private var commandPaletteResolvedSearchScope: CommandPaletteListScope? + @State private var commandPaletteResolvedSearchFingerprint: Int? + @State private var isCommandPaletteSearchPending = false + @State private var commandPalettePendingActivation: CommandPalettePendingActivation? + @State private var commandPaletteResultsRevision: UInt64 = 0 + @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] + @State private var isFeedbackComposerPresented = false + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + @FocusState private var isCommandPaletteSearchFocused: Bool + @FocusState private var isCommandPaletteRenameFocused: Bool + + private enum CommandPaletteMode { + case commands + case renameInput(CommandPaletteRenameTarget) + case renameConfirm(CommandPaletteRenameTarget, proposedName: String) + } + + private enum CommandPaletteListScope: String { + case commands + case switcher + } + + enum CommandPalettePendingActivation: Equatable { + case selected(requestID: UInt64, fallbackSelectedIndex: Int, preferredCommandID: String?) + case command(requestID: UInt64, commandID: String) + } + + enum CommandPaletteResolvedActivation: Equatable { + case selected(index: Int) + case command(commandID: String) + } + + private struct CommandPaletteRenameTarget: Equatable { + enum Kind: Equatable { + case workspace(workspaceId: UUID) + case tab(workspaceId: UUID, panelId: UUID) + } + + let kind: Kind + let currentName: String + + var title: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceTitle", defaultValue: "Rename Workspace") + case .tab: + return String(localized: "commandPalette.rename.tabTitle", defaultValue: "Rename Tab") + } + } + + var description: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceDescription", defaultValue: "Choose a custom workspace name.") + case .tab: + return String(localized: "commandPalette.rename.tabDescription", defaultValue: "Choose a custom tab name.") + } + } + + var placeholder: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspacePlaceholder", defaultValue: "Workspace name") + case .tab: + return String(localized: "commandPalette.rename.tabPlaceholder", defaultValue: "Tab name") + } + } + } + + private struct CommandPaletteRestoreFocusTarget { + let workspaceId: UUID + let panelId: UUID + let intent: PanelFocusIntent + } + + private enum CommandPaletteInputFocusTarget { + case search + case rename + } + + private enum CommandPaletteTextSelectionBehavior { + case caretAtEnd + case selectAll + } + + private enum CommandPaletteTrailingLabelStyle { + case shortcut + case kind + } + + private struct CommandPaletteTrailingLabel { + let text: String + let style: CommandPaletteTrailingLabelStyle + } + + private struct CommandPaletteInputFocusPolicy { + let focusTarget: CommandPaletteInputFocusTarget + let selectionBehavior: CommandPaletteTextSelectionBehavior + + static let search = CommandPaletteInputFocusPolicy( + focusTarget: .search, + selectionBehavior: .caretAtEnd + ) + } + + private struct CommandPaletteCommand: Identifiable { + let id: String + let rank: Int + let title: String + let subtitle: String + let shortcutHint: String? + let keywords: [String] + let dismissOnRun: Bool + let action: () -> Void + + var searchableTexts: [String] { + [title, subtitle] + keywords + } + } + + private struct CommandPaletteUsageEntry: Codable, Sendable { + var useCount: Int + var lastUsedAt: TimeInterval + } + + private struct CommandPaletteContextSnapshot { + private var boolValues: [String: Bool] = [:] + private var stringValues: [String: String] = [:] + + mutating func setBool(_ key: String, _ value: Bool) { + boolValues[key] = value + } + + mutating func setString(_ key: String, _ value: String?) { + guard let value, !value.isEmpty else { + stringValues.removeValue(forKey: key) + return + } + stringValues[key] = value + } + + func bool(_ key: String) -> Bool { + boolValues[key] ?? false + } + + func string(_ key: String) -> String? { + stringValues[key] + } + + func fingerprint() -> Int { + ContentView.commandPaletteContextFingerprint( + boolValues: boolValues, + stringValues: stringValues + ) + } + } + + private enum CommandPaletteContextKeys { + static let hasWorkspace = "workspace.hasSelection" + static let workspaceName = "workspace.name" + static let workspaceHasCustomName = "workspace.hasCustomName" + static let workspaceShouldPin = "workspace.shouldPin" + static let workspaceHasPullRequests = "workspace.hasPullRequests" + static let workspaceHasSplits = "workspace.hasSplits" + static let workspaceHasPeers = "workspace.hasPeers" + static let workspaceHasAbove = "workspace.hasAbove" + static let workspaceHasBelow = "workspace.hasBelow" + static let workspaceHasUnread = "workspace.hasUnread" + static let workspaceHasRead = "workspace.hasRead" + + static let hasFocusedPanel = "panel.hasFocus" + static let panelName = "panel.name" + static let panelIsBrowser = "panel.isBrowser" + static let panelIsTerminal = "panel.isTerminal" + static let panelHasCustomName = "panel.hasCustomName" + static let panelShouldPin = "panel.shouldPin" + static let panelHasUnread = "panel.hasUnread" + + static let updateHasAvailable = "update.hasAvailable" + + static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { + "terminal.openTarget.\(target.rawValue).available" + } + } + + private struct CommandPaletteCommandContribution { + let commandId: String + let title: (CommandPaletteContextSnapshot) -> String + let subtitle: (CommandPaletteContextSnapshot) -> String + let shortcutHint: String? + let keywords: [String] + let dismissOnRun: Bool + let when: (CommandPaletteContextSnapshot) -> Bool + let enablement: (CommandPaletteContextSnapshot) -> Bool + + init( + commandId: String, + title: @escaping (CommandPaletteContextSnapshot) -> String, + subtitle: @escaping (CommandPaletteContextSnapshot) -> String, + shortcutHint: String? = nil, + keywords: [String] = [], + dismissOnRun: Bool = true, + when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }, + enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true } + ) { + self.commandId = commandId + self.title = title + self.subtitle = subtitle + self.shortcutHint = shortcutHint + self.keywords = keywords + self.dismissOnRun = dismissOnRun + self.when = when + self.enablement = enablement + } + } + + private struct CommandPaletteHandlerRegistry { + private var handlers: [String: () -> Void] = [:] + + mutating func register(commandId: String, handler: @escaping () -> Void) { + handlers[commandId] = handler + } + + func handler(for commandId: String) -> (() -> Void)? { + handlers[commandId] + } + } + + private struct CommandPaletteSearchResult: Identifiable { + let command: CommandPaletteCommand + let score: Int + let titleMatchIndices: Set<Int> + + var id: String { command.id } + } + + private struct CommandPaletteResolvedSearchMatch: Sendable { + let commandID: String + let score: Int + let titleMatchIndices: Set<Int> + } + + private struct CommandPaletteSwitcherWindowContext { + let windowId: UUID + let tabManager: TabManager + let selectedWorkspaceId: UUID? + let windowLabel: String? + } + + struct CommandPaletteSwitcherFingerprintWorkspace: Sendable { + let id: UUID + let displayName: String + let metadata: CommandPaletteSwitcherSearchMetadata + } + + struct CommandPaletteSwitcherFingerprintContext: Sendable { + let windowId: UUID + let windowLabel: String? + let selectedWorkspaceId: UUID? + let workspaces: [CommandPaletteSwitcherFingerprintWorkspace] + } private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot ) + private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" + private static let commandPaletteCommandsPrefix = ">" + private static let commandPaletteVisiblePreviewResultLimit = 48 + private static let commandPaletteVisiblePreviewCandidateLimit = 192 + private static let minimumSidebarWidth: CGFloat = 186 + private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 private enum SidebarResizerHandle: Hashable { case divider @@ -840,8 +1644,40 @@ struct ContentView: View { SidebarResizeInteraction.hitWidthPerSide } - private var maxSidebarWidth: CGFloat { - (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3 + private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat { + let resolvedAvailableWidth = availableWidth + ?? observedWindow?.contentView?.bounds.width + ?? observedWindow?.contentLayoutRect.width + ?? NSApp.keyWindow?.contentView?.bounds.width + ?? NSApp.keyWindow?.contentLayoutRect.width + if let resolvedAvailableWidth, resolvedAvailableWidth > 0 { + return max(Self.minimumSidebarWidth, resolvedAvailableWidth * Self.maximumSidebarWidthRatio) + } + + let fallbackScreenWidth = NSApp.keyWindow?.screen?.frame.width + ?? NSScreen.main?.frame.width + ?? 1920 + return max(Self.minimumSidebarWidth, fallbackScreenWidth * Self.maximumSidebarWidthRatio) + } + + private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) { + let nextWidth = max( + Self.minimumSidebarWidth, + min(maxSidebarWidth(availableWidth: availableWidth), sidebarWidth) + ) + guard abs(nextWidth - sidebarWidth) > 0.5 else { return } + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth + } + } + + private func normalizedSidebarWidth(_ candidate: CGFloat) -> CGFloat { + let minWidth = CGFloat(SessionPersistencePolicy.minimumSidebarWidth) + let maxWidth = max(minWidth, maxSidebarWidth()) + if !candidate.isFinite { + return CGFloat(SessionPersistencePolicy.defaultSidebarWidth) + } + return max(minWidth, min(maxWidth, candidate)) } private func activateSidebarResizerCursor() { @@ -986,6 +1822,7 @@ struct ContentView: View { private func sidebarResizerHandleOverlay( _ handle: SidebarResizerHandle, width: CGFloat, + availableWidth: CGFloat, accessibilityIdentifier: String? = nil ) -> some View { Color.clear @@ -1031,7 +1868,10 @@ struct ContentView: View { activateSidebarResizerCursor() let startWidth = sidebarDragStartWidth ?? sidebarWidth - let nextWidth = max(186, min(maxSidebarWidth, startWidth + value.translation.width)) + let nextWidth = max( + Self.minimumSidebarWidth, + min(maxSidebarWidth(availableWidth: availableWidth), startWidth + value.translation.width) + ) withTransaction(Transaction(animation: nil)) { sidebarWidth = nextWidth } @@ -1062,6 +1902,7 @@ struct ContentView: View { sidebarResizerHandleOverlay( .divider, width: sidebarResizerHitWidthPerSide * 2, + availableWidth: totalWidth, accessibilityIdentifier: "SidebarResizer" ) @@ -1070,12 +1911,19 @@ struct ContentView: View { .allowsHitTesting(false) } .frame(width: totalWidth, height: proxy.size.height, alignment: .leading) + .onAppear { + clampSidebarWidthIfNeeded(availableWidth: totalWidth) + } + .onChange(of: totalWidth) { + clampSidebarWidthIfNeeded(availableWidth: totalWidth) + } } } private var sidebarView: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, + onSendFeedback: presentFeedbackComposer, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex @@ -1098,26 +1946,46 @@ struct ContentView: View { ForEach(mountedWorkspaces) { tab in let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id - let isInputActive = isSelectedWorkspace || isRetiringWorkspace + let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id) + // Keep the retiring workspace visible during handoff, but never input-active. + // Allowing both selected+retiring workspaces to be input-active lets the + // old workspace steal first responder (notably with WKWebView), which can + // delay handoff completion and make browser returns feel laggy. + let isInputActive = isSelectedWorkspace let isVisible = isSelectedWorkspace || isRetiringWorkspace let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( workspace: tab, isWorkspaceVisible: isVisible, isWorkspaceInputActive: isInputActive, - workspacePortalPriority: portalPriority + workspacePortalPriority: portalPriority, + onThemeRefreshRequest: { reason, eventId, source, payloadHex in + scheduleTitlebarThemeRefreshFromWorkspace( + workspaceId: tab.id, + reason: reason, + backgroundEventId: eventId, + backgroundSource: source, + notificationPayloadHex: payloadHex + ) + } ) .opacity(isVisible ? 1 : 0) .allowsHitTesting(isSelectedWorkspace) + .accessibilityHidden(!isVisible) .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) + .task(id: shouldPrimeInBackground ? tab.id : nil) { + await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id) + } } } .opacity(sidebarSelectionState.selection == .tabs ? 1 : 0) .allowsHitTesting(sidebarSelectionState.selection == .tabs) + .accessibilityHidden(sidebarSelectionState.selection != .tabs) NotificationsPage(selection: $sidebarSelectionState.selection) .opacity(sidebarSelectionState.selection == .notifications ? 1 : 0) .allowsHitTesting(sidebarSelectionState.selection == .notifications) + .accessibilityHidden(sidebarSelectionState.selection != .notifications) } .padding(.top, titlebarPadding) .overlay(alignment: .top) { @@ -1138,19 +2006,11 @@ struct ContentView: View { // Background glass settings @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 - @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true + @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false @AppStorage("debugTitlebarLeadingExtra") private var debugTitlebarLeadingExtra: Double = 0 @State private var titlebarLeadingInset: CGFloat = 12 private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } - private var fakeTitlebarBackground: Color { - _ = titlebarThemeGeneration - let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor - let configuredOpacity = CGFloat(max(0, min(1, GhosttyApp.shared.defaultBackgroundOpacity))) - let minimumChromeOpacity: CGFloat = ghosttyBackground.isLightColor ? 0.90 : 0.84 - let chromeOpacity = max(minimumChromeOpacity, configuredOpacity) - return Color(nsColor: ghosttyBackground.withAlphaComponent(chromeOpacity)) - } private var fakeTitlebarTextColor: Color { _ = titlebarThemeGeneration let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor @@ -1162,7 +2022,7 @@ struct ContentView: View { TitlebarControlsView( notificationStore: TerminalNotificationStore.shared, viewModel: fullscreenControlsViewModel, - onToggleSidebar: { AppDelegate.shared?.sidebarState?.toggle() }, + onToggleSidebar: { sidebarState.toggle() }, onToggleNotifications: { [fullscreenControlsViewModel] in AppDelegate.shared?.toggleNotificationsPopover( animated: true, @@ -1180,6 +2040,7 @@ struct ContentView: View { WindowDragHandleView() TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) + .allowsHitTesting(false) HStack(spacing: 8) { if isFullScreen && !sidebarState.isVisible { @@ -1195,6 +2056,7 @@ struct ContentView: View { .font(.system(size: 13, weight: .bold)) .foregroundColor(fakeTitlebarTextColor) .lineLimit(1) + .allowsHitTesting(false) Spacer() @@ -1207,10 +2069,17 @@ struct ContentView: View { .frame(height: titlebarPadding) .frame(maxWidth: .infinity) .contentShape(Rectangle()) - .onTapGesture(count: 2) { - NSApp.keyWindow?.zoom(nil) - } - .background(fakeTitlebarBackground) + .background({ + // The terminal area has two stacked semi-transparent layers: the Bonsplit + // container chrome background plus Ghostty's own Metal-rendered background. + // Compute the effective composited opacity so the titlebar matches visually. + let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) + let effective = alpha >= 0.999 ? alpha : 1.0 - pow(1.0 - alpha, 2) + return TitlebarLayerBackground( + backgroundColor: GhosttyApp.shared.defaultBackgroundColor, + opacity: effective + ) + }()) .overlay(alignment: .bottom) { Rectangle() .fill(Color(nsColor: .separatorColor)) @@ -1238,12 +2107,47 @@ struct ContentView: View { } } - private func scheduleTitlebarThemeRefresh() { - titlebarThemeUpdateCoalescer.signal { - titlebarThemeGeneration &+= 1 + private func scheduleTitlebarThemeRefresh( + reason: String, + backgroundEventId: UInt64? = nil, + backgroundSource: String? = nil, + notificationPayloadHex: String? = nil + ) { + let previousGeneration = titlebarThemeGeneration + titlebarThemeGeneration &+= 1 + if GhosttyApp.shared.backgroundLogEnabled { + let eventLabel = backgroundEventId.map(String.init) ?? "nil" + let sourceLabel = backgroundSource ?? "nil" + let payloadLabel = notificationPayloadHex ?? "nil" + GhosttyApp.shared.logBackground( + "titlebar theme refresh scheduled reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousGeneration=\(previousGeneration) generation=\(titlebarThemeGeneration) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) } } + private func scheduleTitlebarThemeRefreshFromWorkspace( + workspaceId: UUID, + reason: String, + backgroundEventId: UInt64?, + backgroundSource: String?, + notificationPayloadHex: String? + ) { + guard tabManager.selectedTabId == workspaceId else { + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground( + "titlebar theme refresh skipped workspace=\(workspaceId.uuidString) selected=\(tabManager.selectedTabId?.uuidString ?? "nil") reason=\(reason)" + ) + return + } + + scheduleTitlebarThemeRefresh( + reason: reason, + backgroundEventId: backgroundEventId, + backgroundSource: backgroundSource, + notificationPayloadHex: notificationPayloadHex + ) + } + private var focusedDirectory: String? { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { @@ -1301,6 +2205,7 @@ struct ContentView: View { var body: some View { var view = AnyView( contentAndSidebarLayout + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .topLeading) { if isFullScreen && sidebarState.isVisible { fullscreenControls @@ -1308,7 +2213,7 @@ struct ContentView: View { .padding(.top, 4) } } - .frame(minWidth: 800, minHeight: 600) + .frame(minWidth: CGFloat(SessionPersistencePolicy.minimumWindowWidth), minHeight: CGFloat(SessionPersistencePolicy.minimumWindowHeight)) .background(Color.clear) ) @@ -1317,11 +2222,62 @@ struct ContentView: View { reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId installSidebarResizerPointerMonitorIfNeeded() + let restoredWidth = normalizedSidebarWidth(sidebarState.persistedWidth) + if abs(sidebarWidth - restoredWidth) > 0.5 { + sidebarWidth = restoredWidth + } + if abs(sidebarState.persistedWidth - restoredWidth) > 0.5 { + sidebarState.persistedWidth = restoredWidth + } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } } updateTitlebarText() + + // Startup recovery (#399): if session restore or a race condition leaves the + // view in a broken state (empty tabs, no selection, unmounted workspaces), + // detect and recover after a short delay. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak tabManager] in + guard let tabManager else { return } + var didRecover = false + + // Ensure there is at least one workspace. + if tabManager.tabs.isEmpty { + tabManager.addWorkspace() + didRecover = true + } + + // Ensure selectedTabId points to an existing workspace. + if tabManager.selectedTabId == nil || !tabManager.tabs.contains(where: { $0.id == tabManager.selectedTabId }) { + tabManager.selectedTabId = tabManager.tabs.first?.id + didRecover = true + } + + // Ensure mountedWorkspaceIds is populated. + if mountedWorkspaceIds.isEmpty || !mountedWorkspaceIds.contains(where: { id in tabManager.tabs.contains { $0.id == id } }) { + reconcileMountedWorkspaceIds() + didRecover = true + } + + // Ensure sidebar selection is valid. + if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + didRecover = true + } + + if didRecover { +#if DEBUG + dlog("startup.recovery tabCount=\(tabManager.tabs.count) selected=\(tabManager.selectedTabId?.uuidString.prefix(8) ?? "nil") mounted=\(mountedWorkspaceIds.count)") +#endif + sentryBreadcrumb("startup.recovery", data: [ + "tabCount": tabManager.tabs.count, + "selectedTabId": tabManager.selectedTabId?.uuidString ?? "nil", + "mountedCount": mountedWorkspaceIds.count + ]) + } + } }) view = AnyView(view.onChange(of: tabManager.selectedTabId) { newValue in @@ -1364,6 +2320,14 @@ struct ContentView: View { reconcileMountedWorkspaceIds() }) + view = AnyView(view.onReceive(tabManager.$pendingBackgroundWorkspaceLoadIds) { _ in + reconcileMountedWorkspaceIds() + }) + + view = AnyView(view.onReceive(tabManager.$debugPinnedWorkspaceLoadIds) { _ in + reconcileMountedWorkspaceIds() + }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } @@ -1382,12 +2346,11 @@ struct ContentView: View { scheduleTitlebarTextRefresh() }) - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyConfigDidReload"))) { _ in - scheduleTitlebarThemeRefresh() - }) - - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyDefaultBackgroundDidChange"))) { _ in - scheduleTitlebarThemeRefresh() + view = AnyView(view.onChange(of: titlebarThemeGeneration) { oldValue, newValue in + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground( + "titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in @@ -1396,6 +2359,25 @@ struct ContentView: View { completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in + guard let webView = notification.object as? WKWebView, + let selectedTabId = tabManager.selectedTabId, + let selectedWorkspace = tabManager.selectedWorkspace, + let focusedPanelId = selectedWorkspace.focusedPanelId, + let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId), + focusedBrowser.webView === webView else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder") + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in + guard let panelId = notification.object as? UUID, + let selectedTabId = tabManager.selectedTabId, + let selectedWorkspace = tabManager.selectedWorkspace, + selectedWorkspace.focusedPanelId == panelId, + selectedWorkspace.browserPanel(for: panelId) != nil else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar") + }) + view = AnyView(view.onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) { @@ -1406,6 +2388,7 @@ struct ContentView: View { if let previousSelectedWorkspaceId, !existingIds.contains(previousSelectedWorkspaceId) { self.previousSelectedWorkspaceId = tabManager.selectedTabId } + tabManager.pruneBackgroundWorkspaceLoads(existingIds: existingIds) reconcileMountedWorkspaceIds(tabs: tabs) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { @@ -1431,6 +2414,143 @@ struct ContentView: View { #endif }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteToggleRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + toggleCommandPalette() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteCommands() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteSwitcherRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteSwitcher() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteSubmitRequested)) { notification in + guard isCommandPalettePresented else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + handleCommandPaletteSubmitRequest() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteDismissRequested)) { notification in + guard isCommandPalettePresented else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + dismissCommandPalette() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameTabRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteRenameTabInput() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameWorkspaceRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteRenameWorkspaceInput() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteMoveSelection)) { notification in + guard isCommandPalettePresented else { return } + guard case .commands = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return } + moveCommandPaletteSelection(by: delta) + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputInteractionRequested)) { notification in + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + handleCommandPaletteRenameInputInteraction() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputDeleteBackwardRequested)) { notification in + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + _ = handleCommandPaletteRenameDeleteBackward(modifiers: []) + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .feedbackComposerRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + presentFeedbackComposer() + }) + + view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in + MainActor.assumeIsolated { + let overlayController = commandPaletteWindowOverlayController(for: window) + overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented) + } + })) + view = AnyView(view.onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() }) @@ -1455,15 +2575,49 @@ struct ContentView: View { AppDelegate.shared?.fullscreenControlsViewModel = nil }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSWindow.didResizeNotification)) { notification in + guard let window = notification.object as? NSWindow, + window === observedWindow else { return } + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) + updateSidebarResizerBandState() + }) + view = AnyView(view.onChange(of: sidebarWidth) { _ in + let sanitized = normalizedSidebarWidth(sidebarWidth) + if abs(sidebarWidth - sanitized) > 0.5 { + sidebarWidth = sanitized + return + } + if abs(sidebarState.persistedWidth - sanitized) > 0.5 { + sidebarState.persistedWidth = sanitized + } + // Sidebar width changes are pure SwiftUI layout updates, so portal-hosted + // terminals need an explicit post-layout geometry resync. + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() updateSidebarResizerBandState() }) view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() updateSidebarResizerBandState() }) + view = AnyView(view.onChange(of: sidebarState.persistedWidth) { newValue in + let sanitized = normalizedSidebarWidth(newValue) + if abs(newValue - sanitized) > 0.5 { + sidebarState.persistedWidth = sanitized + return + } + guard !isResizerDragging else { return } + if abs(sidebarWidth - sanitized) > 0.5 { + sidebarWidth = sanitized + } + }) + view = AnyView(view.ignoresSafeArea()) + view = AnyView(view.sheet(isPresented: $isFeedbackComposerPresented) { + SidebarFeedbackComposerSheet() + }) view = AnyView(view.onDisappear { removeSidebarResizerPointerMonitor() @@ -1475,6 +2629,9 @@ struct ContentView: View { // Do not make the entire background draggable; it interferes with drag gestures // like sidebar tab reordering in multi-window mode. window.isMovableByWindowBackground = false + // Keep the window immovable by default so titlebar controls (like the folder icon) + // cannot accidentally initiate native window drags. + window.isMovable = false window.styleMask.insert(.fullSizeContentView) // Track this window for fullscreen notifications @@ -1482,6 +2639,8 @@ struct ContentView: View { DispatchQueue.main.async { observedWindow = window isFullScreen = window.styleMask.contains(.fullScreen) + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) + syncCommandPaletteDebugStateForObservedWindow() installSidebarResizerPointerMonitorIfNeeded() updateSidebarResizerBandState() } @@ -1504,23 +2663,31 @@ struct ContentView: View { // Background glass: skip on macOS 26+ where NSGlassEffectView can cause blank // or incorrectly tinted SwiftUI content. Keep native window rendering there so // Ghostty theme colors remain authoritative. - if sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue + let currentThemeBackground = GhosttyBackgroundTheme.currentColor() + let shouldApplyWindowGlassFallback = + sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue && bgGlassEnabled - && !WindowGlassEffect.isAvailable { + && !WindowGlassEffect.isAvailable + let shouldForceTransparentHosting = + shouldApplyWindowGlassFallback || currentThemeBackground.alphaComponent < 0.999 + + if shouldForceTransparentHosting { window.isOpaque = false - window.backgroundColor = .clear - // Configure contentView and all subviews for transparency + // Keep the window clear whenever translucency is active. Relying only on + // terminal focus-driven updates can leave stale opaque window fills. + window.backgroundColor = NSColor.white.withAlphaComponent(0.001) + // Configure contentView hierarchy for transparency. if let contentView = window.contentView { - contentView.wantsLayer = true - contentView.layer?.backgroundColor = NSColor.clear.cgColor - contentView.layer?.isOpaque = false - // Make SwiftUI hosting view transparent - for subview in contentView.subviews { - subview.wantsLayer = true - subview.layer?.backgroundColor = NSColor.clear.cgColor - subview.layer?.isOpaque = false - } + makeViewHierarchyTransparent(contentView) } + } else { + // Browser-focused workspaces may not have an active terminal panel to refresh + // the NSWindow background. Keep opaque theme changes applied here as well. + window.backgroundColor = currentThemeBackground + window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 + } + + if shouldApplyWindowGlassFallback { // Apply liquid glass effect to the window with tint from settings let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) WindowGlassEffect.apply(to: window, tintColor: tintColor) @@ -1544,9 +2711,12 @@ struct ContentView: View { let currentTabs = tabs ?? tabManager.tabs let orderedTabIds = currentTabs.map { $0.id } let effectiveSelectedId = selectedId ?? tabManager.selectedTabId - let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] + let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] + let pinnedIds = handoffPinnedIds + .union(tabManager.pendingBackgroundWorkspaceLoadIds) + .union(tabManager.debugPinnedWorkspaceLoadIds) let isCycleHot = tabManager.isWorkspaceCycleHot - let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty + let shouldKeepHandoffPair = isCycleHot && !handoffPinnedIds.isEmpty let baseMaxMounted = shouldKeepHandoffPair ? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle : WorkspaceMountPolicy.maxMountedWorkspaces @@ -1583,11 +2753,96 @@ struct ContentView: View { #endif } + private enum BackgroundWorkspacePrimeState { + case pending + case completed(reason: String) + } + + private enum BackgroundWorkspacePrimePolicy { + static let timeoutSeconds: TimeInterval = 2.0 + static let pollIntervalNanoseconds: UInt64 = 50_000_000 + } + + private func primeBackgroundWorkspaceIfNeeded(workspaceId: UUID) async { + let shouldPrime = await MainActor.run { + tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) + } + guard shouldPrime else { return } + +#if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + dlog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))") +#endif + + let timeout = Date().addingTimeInterval(BackgroundWorkspacePrimePolicy.timeoutSeconds) + while !Task.isCancelled { + let state = await MainActor.run { + stepBackgroundWorkspacePrime(workspaceId: workspaceId) + } + switch state { + case .pending: + if Date() < timeout { + try? await Task.sleep(nanoseconds: BackgroundWorkspacePrimePolicy.pollIntervalNanoseconds) + continue + } + await MainActor.run { + tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 + dlog( + "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + + "reason=timeout ms=\(String(format: "%.2f", elapsedMs))" + ) +#endif + return + case .completed(let reason): +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 + dlog( + "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + + "reason=\(reason) ms=\(String(format: "%.2f", elapsedMs))" + ) +#endif + return + } + } + } + + @MainActor + private func stepBackgroundWorkspacePrime(workspaceId: UUID) -> BackgroundWorkspacePrimeState { + guard tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { + return .completed(reason: "already_cleared") + } + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + return .completed(reason: "workspace_removed") + } + + workspace.requestBackgroundTerminalSurfaceStartIfNeeded() + guard workspace.hasLoadedTerminalSurface() else { + return .pending + } + + tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + return .completed(reason: "surface_ready") + } + private func addTab() { tabManager.addTab() sidebarSelectionState.selection = .tabs } + private func makeViewHierarchyTransparent(_ root: NSView) { + var stack: [NSView] = [root] + while let view = stack.popLast() { + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.clear.cgColor + view.layer?.isOpaque = false + stack.append(contentsOf: view.subviews) + } + } + private func updateWindowGlassTint() { // Find this view's main window by identifier (keyWindow might be a debug panel/settings). guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == windowIdentifier }) else { return } @@ -1660,13 +2915,14 @@ struct ContentView: View { workspaceHandoffFallbackTask = nil let retiring = retiringWorkspaceId - // Hide terminal portal views for the retiring workspace BEFORE clearing + // Hide portal-hosted views for the retiring workspace BEFORE clearing // retiringWorkspaceId. Once cleared, reconcileMountedWorkspaceIds unmounts // the workspace — but dismantleNSView intentionally doesn't hide portal views - // (to avoid blackouts during transient bonsplit dismantles). Hiding here - // prevents stale portal-hosted terminals from covering browser panes. + // during transient rebuilds. Hiding here prevents stale terminal/browser + // portals from covering the newly selected workspace. if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) { workspace.hideAllTerminalPortalViews() + workspace.hideAllBrowserPortalViews() } retiringWorkspaceId = nil @@ -1683,6 +2939,3242 @@ struct ContentView: View { #endif } + private var commandPaletteOverlay: some View { + GeometryReader { proxy in + let maxAllowedWidth = max(340, proxy.size.width - 260) + let targetWidth = min(560, maxAllowedWidth) + + ZStack(alignment: .top) { + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { value in + handleCommandPaletteBackdropClick(atContentPoint: value.location) + } + ) + + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .allowsHitTesting(false) + .accessibilityIdentifier("CommandPaletteBackdrop") + + VStack(spacing: 0) { + switch commandPaletteMode { + case .commands: + commandPaletteCommandListView + case .renameInput(let target): + commandPaletteRenameInputView(target: target) + case let .renameConfirm(target, proposedName): + commandPaletteRenameConfirmView(target: target, proposedName: proposedName) + } + } + .frame(width: targetWidth) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.98)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.24), radius: 10, x: 0, y: 5) + .padding(.top, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onExitCommand { + dismissCommandPalette() + } + .zIndex(2000) + } + + private var commandPaletteCommandListView: some View { + let visibleResults = commandPaletteVisibleResults + let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + let commandPaletteListMaxHeight: CGFloat = 450 + let commandPaletteRowHeight: CGFloat = 24 + let commandPaletteEmptyStateHeight: CGFloat = 44 + let commandPaletteListContentHeight = visibleResults.isEmpty + ? commandPaletteEmptyStateHeight + : CGFloat(visibleResults.count) * commandPaletteRowHeight + let commandPaletteListHeight = min(commandPaletteListMaxHeight, commandPaletteListContentHeight) + return VStack(spacing: 0) { + HStack(spacing: 8) { + TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .regular)) + .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) + .focused($isCommandPaletteSearchFocused) + .accessibilityIdentifier("CommandPaletteSearchField") + .onSubmit { + runSelectedCommandPaletteResult() + } + .backport.onKeyPress(.downArrow) { _ in + moveCommandPaletteSelection(by: 1) + return .handled + } + .backport.onKeyPress(.upArrow) { _ in + moveCommandPaletteSelection(by: -1) + return .handled + } + .backport.onKeyPress("n") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) + } + .backport.onKeyPress("p") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) + } + .backport.onKeyPress("j") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) + } + .backport.onKeyPress("k") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) + } + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + ScrollView { + LazyVStack(spacing: 0) { + if visibleResults.isEmpty { + if commandPaletteHasCurrentResolvedResults { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: commandPaletteEmptyStateHeight) + } + } else { + ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in + let isSelected = index == selectedIndex + let isHovered = commandPaletteHoveredResultIndex == index + let rowBackground: Color = isSelected + ? cmuxAccentColor().opacity(0.12) + : (isHovered ? Color.primary.opacity(0.08) : .clear) + + Button { + runCommandPaletteResult(commandID: result.id) + } label: { + HStack(spacing: 8) { + commandPaletteHighlightedTitleText( + result.command.title, + matchedIndices: result.titleMatchIndices + ) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + Spacer() + + if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { + switch trailingLabel.style { + case .shortcut: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + case .kind: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.horizontal, 9) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .id(index) + .onHover { hovering in + if hovering { + commandPaletteHoveredResultIndex = index + } else if commandPaletteHoveredResultIndex == index { + commandPaletteHoveredResultIndex = nil + } + } + } + } + } + .scrollTargetLayout() + // Force a fresh row tree per query so rendered labels/actions stay in lockstep. + .id(commandPaletteQuery) + } + .frame(height: commandPaletteListHeight) + .scrollPosition( + id: Binding( + get: { commandPaletteScrollTargetIndex }, + // Ignore passive readback so manual scrolling doesn't mutate selection-follow state. + set: { _ in } + ), + anchor: commandPaletteScrollTargetAnchor + ) + .onChange(of: commandPaletteSelectedResultIndex) { _ in + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true) + } + + // Keep Esc-to-close behavior without showing footer controls. + Button(action: { dismissCommandPalette() }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + .onAppear { + commandPaletteHoveredResultIndex = nil + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) + resetCommandPaletteSearchFocus() + } + .onChange(of: commandPaletteQuery) { _ in + commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil + commandPaletteHoveredResultIndex = nil + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + scheduleCommandPaletteResultsRefresh() + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: commandPaletteCurrentSearchFingerprint) { _ in + scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: commandPaletteResultsRevision) { _ in + let resultIDs = cachedCommandPaletteResults.map(\.id) + commandPaletteSelectedResultIndex = Self.commandPaletteResolvedSelectionIndex( + preferredCommandID: commandPaletteSelectionAnchorCommandID, + fallbackSelectedIndex: commandPaletteSelectedResultIndex, + resultIDs: resultIDs + ) + syncCommandPaletteSelectionAnchorFromCurrentResults() + let visibleResultCount = commandPaletteVisibleResults.count + updateCommandPaletteScrollTarget(resultCount: visibleResultCount, animated: false) + if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResultCount { + commandPaletteHoveredResultIndex = nil + } + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: commandPaletteSelectedResultIndex) { _ in + syncCommandPaletteDebugStateForObservedWindow() + } + } + + private func commandPaletteRenameInputView(target: CommandPaletteRenameTarget) -> some View { + VStack(spacing: 0) { + TextField(target.placeholder, text: $commandPaletteRenameDraft) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .regular)) + .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) + .focused($isCommandPaletteRenameFocused) + .accessibilityIdentifier("CommandPaletteRenameField") + .backport.onKeyPress(.delete) { modifiers in + handleCommandPaletteRenameDeleteBackward(modifiers: modifiers) + } + .onSubmit { + continueRenameFlow(target: target) + } + .onTapGesture { + handleCommandPaletteRenameInputInteraction() + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + Text(renameInputHintText(target: target)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 6) + + Button(action: { + continueRenameFlow(target: target) + }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + .onAppear { + resetCommandPaletteRenameFocus() + } + } + + private func commandPaletteRenameConfirmView( + target: CommandPaletteRenameTarget, + proposedName: String + ) -> some View { + let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + let nextName = trimmedName.isEmpty ? String(localized: "commandPalette.rename.clearCustomName", defaultValue: "(clear custom name)") : trimmedName + + return VStack(spacing: 0) { + Text(nextName) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + Text(renameConfirmHintText(target: target)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 6) + + Button(action: { + applyRenameFlow(target: target, proposedName: proposedName) + }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + } + + private func renameInputHintText(target: CommandPaletteRenameTarget) -> String { + switch target.kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceInputHint", defaultValue: "Enter a workspace name. Press Enter to rename, Escape to cancel.") + case .tab: + return String(localized: "commandPalette.rename.tabInputHint", defaultValue: "Enter a tab name. Press Enter to rename, Escape to cancel.") + } + } + + private func renameConfirmHintText(target: CommandPaletteRenameTarget) -> String { + switch target.kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceConfirmHint", defaultValue: "Press Enter to apply this workspace name, or Escape to cancel.") + case .tab: + return String(localized: "commandPalette.rename.tabConfirmHint", defaultValue: "Press Enter to apply this tab name, or Escape to cancel.") + } + } + + private var commandPaletteListScope: CommandPaletteListScope { + if commandPaletteQuery.hasPrefix(Self.commandPaletteCommandsPrefix) { + return .commands + } + return .switcher + } + + private var commandPaletteCurrentSearchFingerprint: Int { + commandPaletteEntriesFingerprint(for: commandPaletteListScope) + } + + private var commandPaletteSearchPlaceholder: String { + switch commandPaletteListScope { + case .commands: + return String(localized: "commandPalette.search.commandsPlaceholder", defaultValue: "Type a command") + case .switcher: + return String(localized: "commandPalette.search.switcherPlaceholder", defaultValue: "Search workspaces") + } + } + + private var commandPaletteEmptyStateText: String { + switch commandPaletteListScope { + case .commands: + return String(localized: "commandPalette.search.commandsEmpty", defaultValue: "No commands match your search.") + case .switcher: + return String(localized: "commandPalette.search.switcherEmpty", defaultValue: "No workspaces match your search.") + } + } + + private var commandPaletteQueryForMatching: String { + switch commandPaletteListScope { + case .commands: + let suffix = String(commandPaletteQuery.dropFirst(Self.commandPaletteCommandsPrefix.count)) + return suffix.trimmingCharacters(in: .whitespacesAndNewlines) + case .switcher: + return commandPaletteQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] { + switch scope { + case .commands: + return commandPaletteCommands() + case .switcher: + return commandPaletteSwitcherEntries() + } + } + + private func refreshCommandPaletteSearchCorpus(force: Bool = false) { + let scope = commandPaletteListScope + let fingerprint = commandPaletteEntriesFingerprint(for: scope) + guard force || cachedCommandPaletteScope != scope || cachedCommandPaletteFingerprint != fingerprint else { + return + } + + let entries = commandPaletteEntries(for: scope) + commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) + let searchCorpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + commandPaletteSearchCorpus = searchCorpus + commandPaletteSearchCorpusByID = Dictionary(uniqueKeysWithValues: searchCorpus.map { ($0.payload, $0) }) + cachedCommandPaletteScope = scope + cachedCommandPaletteFingerprint = fingerprint + } + + private func cancelCommandPaletteSearch() { + commandPaletteSearchTask?.cancel() + commandPaletteSearchTask = nil + } + + nonisolated private static func commandPaletteResolvedSearchMatches( + searchCorpus: [CommandPaletteSearchCorpusEntry<String>], + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval, + shouldCancel: @escaping () -> Bool = { false } + ) -> [CommandPaletteResolvedSearchMatch] { + let results = CommandPaletteSearchEngine.search( + entries: searchCorpus, + query: query, + historyBoost: { commandId, _ in + Self.commandPaletteHistoryBoost( + for: commandId, + queryIsEmpty: queryIsEmpty, + history: usageHistory, + now: historyTimestamp + ) + }, + shouldCancel: shouldCancel + ) + + return results.map { result in + CommandPaletteResolvedSearchMatch( + commandID: result.payload, + score: result.score, + titleMatchIndices: result.titleMatchIndices + ) + } + } + + private static func commandPaletteMaterializedSearchResults( + matches: [CommandPaletteResolvedSearchMatch], + commandsByID: [String: CommandPaletteCommand] + ) -> [CommandPaletteSearchResult] { + matches.compactMap { match in + guard let command = commandsByID[match.commandID] else { return nil } + return CommandPaletteSearchResult( + command: command, + score: match.score, + titleMatchIndices: match.titleMatchIndices + ) + } + } + + private func setCommandPaletteVisibleResults( + _ results: [CommandPaletteSearchResult], + scope: CommandPaletteListScope, + fingerprint: Int? + ) { + commandPaletteVisibleResults = results + commandPaletteVisibleResultsScope = scope + commandPaletteVisibleResultsFingerprint = fingerprint + } + + private func refreshPendingCommandPaletteVisibleResults( + scope: CommandPaletteListScope, + fingerprint: Int?, + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval + ) { + let candidateCommandIDs: [String] + if commandPaletteVisibleResultsScope == scope, + commandPaletteVisibleResultsFingerprint == fingerprint { + candidateCommandIDs = Self.commandPalettePreviewCandidateCommandIDs( + resultIDs: commandPaletteVisibleResults.map(\.id), + limit: Self.commandPaletteVisiblePreviewCandidateLimit + ) + } else { + candidateCommandIDs = [] + } + + let previewMatches = Self.commandPalettePreviewSearchMatches( + scope: scope, + searchCorpus: commandPaletteSearchCorpus, + candidateCommandIDs: candidateCommandIDs, + searchCorpusByID: commandPaletteSearchCorpusByID, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp, + resultLimit: Self.commandPaletteVisiblePreviewResultLimit + ) + let previewResults = Self.commandPaletteMaterializedSearchResults( + matches: previewMatches, + commandsByID: commandPaletteSearchCommandsByID + ) + setCommandPaletteVisibleResults( + previewResults, + scope: scope, + fingerprint: fingerprint + ) + } + + nonisolated private static func commandPalettePreviewSearchMatches( + scope: CommandPaletteListScope, + searchCorpus: [CommandPaletteSearchCorpusEntry<String>], + candidateCommandIDs: [String], + searchCorpusByID: [String: CommandPaletteSearchCorpusEntry<String>], + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval, + resultLimit: Int + ) -> [CommandPaletteResolvedSearchMatch] { + guard resultLimit > 0 else { + return [] + } + + if scope == .commands { + let matches = commandPaletteResolvedSearchMatches( + searchCorpus: searchCorpus, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + guard matches.count > resultLimit else { + return matches + } + return Array(matches.prefix(resultLimit)) + } + + guard !candidateCommandIDs.isEmpty else { + return [] + } + + var seenCommandIDs: Set<String> = [] + let previewEntries: [CommandPaletteSearchCorpusEntry<String>] = candidateCommandIDs.compactMap { commandID in + guard seenCommandIDs.insert(commandID).inserted else { return nil } + return searchCorpusByID[commandID] + } + guard !previewEntries.isEmpty else { + return [] + } + + let matches = commandPaletteResolvedSearchMatches( + searchCorpus: previewEntries, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + guard matches.count > resultLimit else { + return matches + } + return Array(matches.prefix(resultLimit)) + } + + nonisolated static func commandPaletteCommandPreviewMatchCommandIDsForTests( + searchCorpus: [CommandPaletteSearchCorpusEntry<String>], + candidateCommandIDs: [String], + searchCorpusByID: [String: CommandPaletteSearchCorpusEntry<String>], + query: String, + resultLimit: Int + ) -> [String] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + return commandPalettePreviewSearchMatches( + scope: .commands, + searchCorpus: searchCorpus, + candidateCommandIDs: candidateCommandIDs, + searchCorpusByID: searchCorpusByID, + query: query, + usageHistory: [:], + queryIsEmpty: preparedQuery.isEmpty, + historyTimestamp: 0, + resultLimit: resultLimit + ).map(\.commandID) + } + + static func commandPalettePreviewCandidateCommandIDs( + resultIDs: [String], + limit: Int + ) -> [String] { + guard limit > 0 else { return [] } + guard resultIDs.count > limit else { return resultIDs } + return Array(resultIDs.prefix(limit)) + } + + static func commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: Bool + ) -> Bool { + !hasVisibleResultsForScope + } + + private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { + refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) + + commandPaletteSearchRequestID &+= 1 + let requestID = commandPaletteSearchRequestID + let query = commandPaletteQueryForMatching + let scope = commandPaletteListScope + let fingerprint = cachedCommandPaletteFingerprint + let searchCorpus = commandPaletteSearchCorpus + let commandsByID = commandPaletteSearchCommandsByID + let usageHistory = commandPaletteUsageHistoryByCommandId + let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty + let historyTimestamp = Date().timeIntervalSince1970 + commandPalettePendingActivation = nil + cancelCommandPaletteSearch() + if Self.commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: commandPaletteVisibleResultsScope == scope + ) { + let matches = Self.commandPaletteResolvedSearchMatches( + searchCorpus: searchCorpus, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + cachedCommandPaletteResults = Self.commandPaletteMaterializedSearchResults( + matches: matches, + commandsByID: commandsByID + ) + commandPaletteResolvedSearchRequestID = requestID + commandPaletteResolvedSearchScope = scope + commandPaletteResolvedSearchFingerprint = fingerprint + isCommandPaletteSearchPending = false + setCommandPaletteVisibleResults( + cachedCommandPaletteResults, + scope: scope, + fingerprint: fingerprint + ) + commandPaletteResultsRevision &+= 1 + return + } + refreshPendingCommandPaletteVisibleResults( + scope: scope, + fingerprint: fingerprint, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + isCommandPaletteSearchPending = true + + commandPaletteSearchTask = Task.detached(priority: .userInitiated) { + let matches = Self.commandPaletteResolvedSearchMatches( + searchCorpus: searchCorpus, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp, + shouldCancel: { Task.isCancelled } + ) + + guard !Task.isCancelled else { return } + + await MainActor.run { + guard commandPaletteSearchRequestID == requestID, + isCommandPalettePresented, + commandPaletteListScope == scope, + commandPaletteQueryForMatching == query, + cachedCommandPaletteFingerprint == fingerprint else { + return + } + + cachedCommandPaletteResults = Self.commandPaletteMaterializedSearchResults( + matches: matches, + commandsByID: commandPaletteSearchCommandsByID + ) + let resultIDs = cachedCommandPaletteResults.map(\.id) + let pendingActivation = commandPalettePendingActivation + let resolvedActivation = Self.commandPaletteResolvedPendingActivation( + pendingActivation, + requestID: requestID, + resultIDs: resultIDs + ) + commandPaletteResolvedSearchRequestID = requestID + commandPaletteResolvedSearchScope = scope + commandPaletteResolvedSearchFingerprint = fingerprint + isCommandPaletteSearchPending = false + setCommandPaletteVisibleResults( + cachedCommandPaletteResults, + scope: scope, + fingerprint: fingerprint + ) + if Self.commandPalettePendingActivationRequestID(pendingActivation) == requestID { + commandPalettePendingActivation = nil + } + commandPaletteResultsRevision &+= 1 + if commandPaletteSearchRequestID == requestID { + commandPaletteSearchTask = nil + } + if let resolvedActivation { + runCommandPaletteResolvedActivation(resolvedActivation) + } + } + } + } + + private func commandPaletteEntriesFingerprint(for scope: CommandPaletteListScope) -> Int { + switch scope { + case .commands: + return commandPaletteCommandsFingerprint() + case .switcher: + return commandPaletteSwitcherEntriesFingerprint() + } + } + + private func commandPaletteCommandsFingerprint() -> Int { + var hasher = Hasher() + hasher.combine(commandPaletteContextSnapshot().fingerprint()) + hasher.combine(AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false) + return hasher.finalize() + } + + private func commandPaletteSwitcherEntriesFingerprint() -> Int { + let windowContexts = commandPaletteSwitcherWindowContexts() + let fingerprintContexts = windowContexts.map { context in + CommandPaletteSwitcherFingerprintContext( + windowId: context.windowId, + windowLabel: context.windowLabel, + selectedWorkspaceId: context.selectedWorkspaceId, + workspaces: commandPaletteOrderedSwitcherWorkspaces(for: context).map { workspace in + CommandPaletteSwitcherFingerprintWorkspace( + id: workspace.id, + displayName: workspaceDisplayName(workspace), + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace) + ) + } + ) + } + return Self.commandPaletteSwitcherFingerprint(windowContexts: fingerprintContexts) + } + + private func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set<Int>) -> Text { + guard !matchedIndices.isEmpty else { + return Text(title).foregroundColor(.primary) + } + + let chars = Array(title) + var index = 0 + var result = Text("") + + while index < chars.count { + let isMatched = matchedIndices.contains(index) + var end = index + 1 + while end < chars.count, matchedIndices.contains(end) == isMatched { + end += 1 + } + + let segment = String(chars[index..<end]) + if isMatched { + result = result + Text(segment).foregroundColor(.blue) + } else { + result = result + Text(segment).foregroundColor(.primary) + } + index = end + } + + return result + } + + private func commandPaletteTrailingLabel(for command: CommandPaletteCommand) -> CommandPaletteTrailingLabel? { + if let shortcutHint = command.shortcutHint { + return CommandPaletteTrailingLabel(text: shortcutHint, style: .shortcut) + } + + guard commandPaletteListScope == .switcher else { return nil } + if command.id.hasPrefix("switcher.workspace.") { + return CommandPaletteTrailingLabel(text: String(localized: "commandPalette.kind.workspace", defaultValue: "Workspace"), style: .kind) + } + return nil + } + + private func commandPaletteSwitcherEntries() -> [CommandPaletteCommand] { + let windowContexts = commandPaletteSwitcherWindowContexts() + guard !windowContexts.isEmpty else { return [] } + + var entries: [CommandPaletteCommand] = [] + let estimatedCount = windowContexts.reduce(0) { partial, context in + partial + context.tabManager.tabs.count + } + entries.reserveCapacity(estimatedCount) + var nextRank = 0 + + for context in windowContexts { + let workspaces = commandPaletteOrderedSwitcherWorkspaces(for: context) + guard !workspaces.isEmpty else { continue } + + let windowId = context.windowId + let windowTabManager = context.tabManager + let windowKeywords = commandPaletteWindowKeywords(windowLabel: context.windowLabel) + for workspace in workspaces { + let workspaceName = workspaceDisplayName(workspace) + let workspaceCommandId = "switcher.workspace.\(workspace.id.uuidString.lowercased())" + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "workspace", + "switch", + "go", + "open", + workspaceName + ] + windowKeywords, + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace), + detail: .workspace + ) + let workspaceId = workspace.id + entries.append( + CommandPaletteCommand( + id: workspaceCommandId, + rank: nextRank, + title: workspaceName, + subtitle: commandPaletteSwitcherSubtitle(base: String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace"), windowLabel: context.windowLabel), + shortcutHint: nil, + keywords: workspaceKeywords, + dismissOnRun: true, + action: { + focusCommandPaletteSwitcherTarget( + windowId: windowId, + tabManager: windowTabManager, + workspaceId: workspaceId + ) + } + ) + ) + nextRank += 1 + } + } + + return entries + } + + private func commandPaletteSwitcherWindowContexts() -> [CommandPaletteSwitcherWindowContext] { + let fallback = CommandPaletteSwitcherWindowContext( + windowId: windowId, + tabManager: tabManager, + selectedWorkspaceId: tabManager.selectedTabId, + windowLabel: nil + ) + + guard let appDelegate = AppDelegate.shared else { return [fallback] } + let summaries = appDelegate.listMainWindowSummaries() + guard !summaries.isEmpty else { return [fallback] } + + let orderedSummaries = summaries.sorted { lhs, rhs in + let lhsIsCurrent = lhs.windowId == windowId + let rhsIsCurrent = rhs.windowId == windowId + if lhsIsCurrent != rhsIsCurrent { return lhsIsCurrent } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + var windowLabelById: [UUID: String] = [:] + if orderedSummaries.count > 1 { + for (index, summary) in orderedSummaries.enumerated() where summary.windowId != windowId { + windowLabelById[summary.windowId] = String(localized: "commandPalette.switcher.windowLabel", defaultValue: "Window \(index + 1)") + } + } + + var contexts: [CommandPaletteSwitcherWindowContext] = [] + var seenWindowIds: Set<UUID> = [] + for summary in orderedSummaries { + guard let manager = appDelegate.tabManagerFor(windowId: summary.windowId) else { continue } + guard seenWindowIds.insert(summary.windowId).inserted else { continue } + contexts.append( + CommandPaletteSwitcherWindowContext( + windowId: summary.windowId, + tabManager: manager, + selectedWorkspaceId: summary.selectedWorkspaceId, + windowLabel: windowLabelById[summary.windowId] + ) + ) + } + + if contexts.isEmpty { + return [fallback] + } + return contexts + } + + private func commandPaletteSwitcherSubtitle(base: String, windowLabel: String?) -> String { + guard let windowLabel else { return base } + return "\(base) • \(windowLabel)" + } + + private func commandPaletteWindowKeywords(windowLabel: String?) -> [String] { + guard let windowLabel else { return [] } + return ["window", windowLabel.lowercased()] + } + + private func commandPaletteOrderedSwitcherWorkspaces( + for context: CommandPaletteSwitcherWindowContext + ) -> [Workspace] { + var workspaces = context.tabManager.tabs + guard !workspaces.isEmpty else { return [] } + + let selectedWorkspaceId = context.selectedWorkspaceId ?? context.tabManager.selectedTabId + if let selectedWorkspaceId, + let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { + let selectedWorkspace = workspaces.remove(at: selectedIndex) + workspaces.insert(selectedWorkspace, at: 0) + } + + return workspaces + } + + private func focusCommandPaletteSwitcherTarget( + windowId: UUID, + tabManager: TabManager, + workspaceId: UUID + ) { + // Switcher commands dismiss the palette after action dispatch. + // Defer focus mutation one turn so browser omnibar autofocus can run + // without being blocked by the palette-visibility guard. + DispatchQueue.main.async { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + tabManager.focusTab(workspaceId, suppressFlash: true) + } + } + + private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata { + // Keep workspace rows coarse and stable for predictable workspace switching queries. + let directories = [workspace.currentDirectory] + let branches = [workspace.gitBranch?.branch].compactMap { $0 } + let ports = workspace.listeningPorts + return CommandPaletteSwitcherSearchMetadata( + directories: directories, + branches: branches, + ports: ports + ) + } + + private func commandPaletteCommands() -> [CommandPaletteCommand] { + let context = commandPaletteContextSnapshot() + let contributions = commandPaletteCommandContributions() + var handlerRegistry = CommandPaletteHandlerRegistry() + registerCommandPaletteHandlers(&handlerRegistry) + + var commands: [CommandPaletteCommand] = [] + commands.reserveCapacity(contributions.count) + var nextRank = 0 + + for contribution in contributions { + guard contribution.when(context), contribution.enablement(context) else { continue } + guard let action = handlerRegistry.handler(for: contribution.commandId) else { + assertionFailure("No command palette handler registered for \(contribution.commandId)") + continue + } + commands.append( + CommandPaletteCommand( + id: contribution.commandId, + rank: nextRank, + title: contribution.title(context), + subtitle: contribution.subtitle(context), + shortcutHint: commandPaletteShortcutHint(for: contribution, context: context), + keywords: contribution.keywords, + dismissOnRun: contribution.dismissOnRun, + action: action + ) + ) + nextRank += 1 + } + + return commands + } + + private func commandPaletteShortcutHint( + for contribution: CommandPaletteCommandContribution, + context: CommandPaletteContextSnapshot + ) -> String? { + // Preserve browser reload semantics for Cmd+R when a browser tab is focused. + if contribution.commandId == "palette.renameTab", + context.bool(CommandPaletteContextKeys.panelIsBrowser) { + return nil + } + if let action = commandPaletteShortcutAction(for: contribution.commandId) { + return KeyboardShortcutSettings.shortcut(for: action).displayString + } + if let staticShortcut = commandPaletteStaticShortcutHint(for: contribution.commandId) { + return staticShortcut + } + return contribution.shortcutHint + } + + private func commandPaletteShortcutAction(for commandId: String) -> KeyboardShortcutSettings.Action? { + switch commandId { + case "palette.newWorkspace": + return .newTab + case "palette.newWindow": + return .newWindow + case "palette.openFolder": + return .openFolder + case "palette.newTerminalTab": + return .newSurface + case "palette.newBrowserTab": + return .openBrowser + case "palette.closeWindow": + return .closeWindow + case "palette.toggleSidebar": + return .toggleSidebar + case "palette.showNotifications": + return .showNotifications + case "palette.jumpUnread": + return .jumpToUnread + case "palette.renameTab": + return .renameTab + case "palette.renameWorkspace": + return .renameWorkspace + case "palette.nextWorkspace": + return .nextSidebarTab + case "palette.previousWorkspace": + return .prevSidebarTab + case "palette.nextTabInPane": + return .nextSurface + case "palette.previousTabInPane": + return .prevSurface + case "palette.browserToggleDevTools": + return .toggleBrowserDeveloperTools + case "palette.browserConsole": + return .showBrowserJavaScriptConsole + case "palette.browserSplitRight", "palette.terminalSplitBrowserRight": + return .splitBrowserRight + case "palette.browserSplitDown", "palette.terminalSplitBrowserDown": + return .splitBrowserDown + case "palette.terminalSplitRight": + return .splitRight + case "palette.terminalSplitDown": + return .splitDown + case "palette.toggleSplitZoom": + return .toggleSplitZoom + case "palette.triggerFlash": + return .triggerFlash + default: + return nil + } + } + + private func commandPaletteStaticShortcutHint(for commandId: String) -> String? { + switch commandId { + case "palette.closeTab": + return "⌘W" + case "palette.closeWorkspace": + return "⌘⇧W" + case "palette.reopenClosedBrowserTab": + return "⌘⇧T" + case "palette.openSettings": + return "⌘," + case "palette.browserBack": + return "⌘[" + case "palette.browserForward": + return "⌘]" + case "palette.browserReload": + return "⌘R" + case "palette.browserFocusAddressBar": + return "⌘L" + case "palette.browserZoomIn": + return "⌘=" + case "palette.browserZoomOut": + return "⌘-" + case "palette.browserZoomReset": + return "⌘0" + case "palette.terminalFind": + return "⌘F" + case "palette.terminalFindNext": + return "⌘G" + case "palette.terminalFindPrevious": + return "⌘⇧G" + case "palette.terminalHideFind": + return "⌘⇧F" + case "palette.terminalUseSelectionForFind": + return "⌘E" + case "palette.toggleFullScreen": + return "\u{2303}\u{2318}F" + default: + return nil + } + } + + private func commandPaletteContextSnapshot() -> CommandPaletteContextSnapshot { + var snapshot = CommandPaletteContextSnapshot() + + if let workspace = tabManager.selectedWorkspace { + snapshot.setBool(CommandPaletteContextKeys.hasWorkspace, true) + snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace)) + snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil) + snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasPullRequests, + !workspace.sidebarPullRequestsInDisplayOrder().isEmpty + ) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasSplits, + workspace.bonsplitController.allPaneIds.count > 1 + ) + let workspaceIndex = tabManager.tabs.firstIndex { $0.id == workspace.id } + snapshot.setBool(CommandPaletteContextKeys.workspaceHasPeers, tabManager.tabs.count > 1) + snapshot.setBool(CommandPaletteContextKeys.workspaceHasAbove, (workspaceIndex ?? 0) > 0) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasBelow, + (workspaceIndex ?? tabManager.tabs.count - 1) < tabManager.tabs.count - 1 + ) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasUnread, + notificationStore.notifications.contains { $0.tabId == workspace.id && !$0.isRead } + ) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasRead, + notificationStore.notifications.contains { $0.tabId == workspace.id && $0.isRead } + ) + } + + if let panelContext = focusedPanelContext { + let workspace = panelContext.workspace + let panelId = panelContext.panelId + let panelIsTerminal = panelContext.panel.panelType == .terminal + snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) + snapshot.setString( + CommandPaletteContextKeys.panelName, + panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle) + ) + snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) + snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) + snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) + snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) + let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) + || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) + snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) + + if panelIsTerminal { + let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + snapshot.setBool( + CommandPaletteContextKeys.terminalOpenTargetAvailable(target), + availableTargets.contains(target) + ) + } + } + } + + if case .updateAvailable = updateViewModel.effectiveState { + snapshot.setBool(CommandPaletteContextKeys.updateHasAvailable, true) + } + + return snapshot + } + + private func commandPaletteCommandContributions() -> [CommandPaletteCommandContribution] { + func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { + { _ in value } + } + + func workspaceSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.workspaceName) ?? String(localized: "commandPalette.subtitle.workspaceFallback", defaultValue: "Workspace") + return String(localized: "commandPalette.subtitle.workspaceWithName", defaultValue: "Workspace • \(name)") + } + + func panelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? String(localized: "commandPalette.subtitle.tabFallback", defaultValue: "Tab") + return String(localized: "commandPalette.subtitle.tabWithName", defaultValue: "Tab • \(name)") + } + + func browserPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? String(localized: "commandPalette.subtitle.tabFallback", defaultValue: "Tab") + return String(localized: "commandPalette.subtitle.browserWithName", defaultValue: "Browser • \(name)") + } + + func terminalPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? String(localized: "commandPalette.subtitle.tabFallback", defaultValue: "Tab") + return String(localized: "commandPalette.subtitle.terminalWithName", defaultValue: "Terminal • \(name)") + } + + var contributions: [CommandPaletteCommandContribution] = [] + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWorkspace", + title: constant(String(localized: "command.newWorkspace.title", defaultValue: "New Workspace")), + subtitle: constant(String(localized: "command.newWorkspace.subtitle", defaultValue: "Workspace")), + keywords: ["create", "new", "workspace"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWindow", + title: constant(String(localized: "command.newWindow.title", defaultValue: "New Window")), + subtitle: constant(String(localized: "command.newWindow.subtitle", defaultValue: "Window")), + keywords: ["create", "new", "window"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.installCLI", + title: constant(String(localized: "command.installCLI.title", defaultValue: "Shell Command: Install 'cmux' in PATH")), + subtitle: constant(String(localized: "command.installCLI.subtitle", defaultValue: "CLI")), + keywords: ["install", "cli", "path", "shell", "command", "symlink"], + when: { _ in !(AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.uninstallCLI", + title: constant(String(localized: "command.uninstallCLI.title", defaultValue: "Shell Command: Uninstall 'cmux' from PATH")), + subtitle: constant(String(localized: "command.uninstallCLI.subtitle", defaultValue: "CLI")), + keywords: ["uninstall", "remove", "cli", "path", "shell", "command", "symlink"], + when: { _ in AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.openFolder", + title: constant(String(localized: "command.openFolder.title", defaultValue: "Open Folder…")), + subtitle: constant(String(localized: "command.openFolder.subtitle", defaultValue: "Workspace")), + keywords: ["open", "folder", "repository", "project", "directory"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newTerminalTab", + title: constant(String(localized: "command.newTerminalTab.title", defaultValue: "New Tab (Terminal)")), + subtitle: constant(String(localized: "command.newTerminalTab.subtitle", defaultValue: "Tab")), + shortcutHint: "⌘T", + keywords: ["new", "terminal", "tab"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newBrowserTab", + title: constant(String(localized: "command.newBrowserTab.title", defaultValue: "New Tab (Browser)")), + subtitle: constant(String(localized: "command.newBrowserTab.subtitle", defaultValue: "Tab")), + shortcutHint: "⌘⇧L", + keywords: ["new", "browser", "tab", "web"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeTab", + title: constant(String(localized: "command.closeTab.title", defaultValue: "Close Tab")), + subtitle: constant(String(localized: "command.closeTab.subtitle", defaultValue: "Tab")), + shortcutHint: "⌘W", + keywords: ["close", "tab"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWorkspace", + title: constant(String(localized: "command.closeWorkspace.title", defaultValue: "Close Workspace")), + subtitle: constant(String(localized: "command.closeWorkspace.subtitle", defaultValue: "Workspace")), + shortcutHint: "⌘⇧W", + keywords: ["close", "workspace"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWindow", + title: constant(String(localized: "command.closeWindow.title", defaultValue: "Close Window")), + subtitle: constant(String(localized: "command.closeWindow.subtitle", defaultValue: "Window")), + keywords: ["close", "window"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleFullScreen", + title: constant(String(localized: "command.toggleFullScreen.title", defaultValue: "Toggle Full Screen")), + subtitle: constant(String(localized: "command.toggleFullScreen.subtitle", defaultValue: "Window")), + keywords: ["fullscreen", "full", "screen", "window", "toggle"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.reopenClosedBrowserTab", + title: constant(String(localized: "command.reopenClosedBrowserTab.title", defaultValue: "Reopen Closed Browser Tab")), + subtitle: constant(String(localized: "command.reopenClosedBrowserTab.subtitle", defaultValue: "Browser")), + shortcutHint: "⌘⇧T", + keywords: ["reopen", "closed", "browser"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleSidebar", + title: constant(String(localized: "command.toggleSidebar.title", defaultValue: "Toggle Sidebar")), + subtitle: constant(String(localized: "command.toggleSidebar.subtitle", defaultValue: "Layout")), + keywords: ["toggle", "sidebar", "layout"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.triggerFlash", + title: constant(String(localized: "command.triggerFlash.title", defaultValue: "Flash Focused Panel")), + subtitle: constant(String(localized: "command.triggerFlash.subtitle", defaultValue: "View")), + keywords: ["flash", "highlight", "focus", "panel"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.showNotifications", + title: constant(String(localized: "command.showNotifications.title", defaultValue: "Show Notifications")), + subtitle: constant(String(localized: "command.showNotifications.subtitle", defaultValue: "Notifications")), + keywords: ["notifications", "inbox"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.jumpUnread", + title: constant(String(localized: "command.jumpUnread.title", defaultValue: "Jump to Latest Unread")), + subtitle: constant(String(localized: "command.jumpUnread.subtitle", defaultValue: "Notifications")), + keywords: ["jump", "unread", "notification"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.openSettings", + title: constant(String(localized: "command.openSettings.title", defaultValue: "Open Settings")), + subtitle: constant(String(localized: "command.openSettings.subtitle", defaultValue: "Global")), + shortcutHint: "⌘,", + keywords: ["settings", "preferences"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.checkForUpdates", + title: constant(String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates")), + subtitle: constant(String(localized: "command.checkForUpdates.subtitle", defaultValue: "Global")), + keywords: ["update", "upgrade", "release"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.applyUpdateIfAvailable", + title: constant(String(localized: "command.applyUpdateIfAvailable.title", defaultValue: "Apply Update (If Available)")), + subtitle: constant(String(localized: "command.applyUpdateIfAvailable.subtitle", defaultValue: "Global")), + keywords: ["apply", "install", "update", "available"], + when: { $0.bool(CommandPaletteContextKeys.updateHasAvailable) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.attemptUpdate", + title: constant(String(localized: "command.attemptUpdate.title", defaultValue: "Attempt Update")), + subtitle: constant(String(localized: "command.attemptUpdate.subtitle", defaultValue: "Global")), + keywords: ["attempt", "check", "update", "upgrade", "release"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.restartSocketListener", + title: constant(String(localized: "command.restartSocketListener.title", defaultValue: "Restart CLI Listener")), + subtitle: constant(String(localized: "command.restartSocketListener.subtitle", defaultValue: "Global")), + keywords: ["restart", "socket", "listener", "cli", "cmux", "control"] + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.renameWorkspace", + title: constant(String(localized: "command.renameWorkspace.title", defaultValue: "Rename Workspace…")), + subtitle: workspaceSubtitle, + keywords: ["rename", "workspace", "title"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.clearWorkspaceName", + title: constant(String(localized: "command.clearWorkspaceName.title", defaultValue: "Clear Workspace Name")), + subtitle: workspaceSubtitle, + keywords: ["clear", "workspace", "name"], + when: { + $0.bool(CommandPaletteContextKeys.hasWorkspace) + && $0.bool(CommandPaletteContextKeys.workspaceHasCustomName) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleWorkspacePin", + title: { context in + context.bool(CommandPaletteContextKeys.workspaceShouldPin) ? String(localized: "command.pinWorkspace.title", defaultValue: "Pin Workspace") : String(localized: "command.unpinWorkspace.title", defaultValue: "Unpin Workspace") + }, + subtitle: workspaceSubtitle, + keywords: ["workspace", "pin", "pinned"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.nextWorkspace", + title: constant(String(localized: "command.nextWorkspace.title", defaultValue: "Next Workspace")), + subtitle: constant(String(localized: "command.nextWorkspace.subtitle", defaultValue: "Workspace Navigation")), + keywords: ["next", "workspace", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.previousWorkspace", + title: constant(String(localized: "command.previousWorkspace.title", defaultValue: "Previous Workspace")), + subtitle: constant(String(localized: "command.previousWorkspace.subtitle", defaultValue: "Workspace Navigation")), + keywords: ["previous", "workspace", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.moveWorkspaceUp", + title: constant(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "move", "up", "reorder"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.moveWorkspaceDown", + title: constant(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "move", "down", "reorder"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasBelow) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.moveWorkspaceToTop", + title: constant(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "move", "top", "reorder"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeOtherWorkspaces", + title: constant(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")), + subtitle: workspaceSubtitle, + keywords: ["close", "other", "workspaces", "reset", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasPeers) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWorkspacesBelow", + title: constant(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")), + subtitle: workspaceSubtitle, + keywords: ["close", "below", "workspaces", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasBelow) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWorkspacesAbove", + title: constant(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")), + subtitle: workspaceSubtitle, + keywords: ["close", "above", "workspaces", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.markWorkspaceRead", + title: constant(String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "read", "notification", "inbox"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasUnread) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.markWorkspaceUnread", + title: constant(String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "unread", "notification", "inbox"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasRead) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.renameTab", + title: constant(String(localized: "command.renameTab.title", defaultValue: "Rename Tab…")), + subtitle: panelSubtitle, + keywords: ["rename", "tab", "title"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.clearTabName", + title: constant(String(localized: "command.clearTabName.title", defaultValue: "Clear Tab Name")), + subtitle: panelSubtitle, + keywords: ["clear", "tab", "name"], + when: { + $0.bool(CommandPaletteContextKeys.hasFocusedPanel) + && $0.bool(CommandPaletteContextKeys.panelHasCustomName) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleTabPin", + title: { context in + context.bool(CommandPaletteContextKeys.panelShouldPin) ? String(localized: "command.pinTab.title", defaultValue: "Pin Tab") : String(localized: "command.unpinTab.title", defaultValue: "Unpin Tab") + }, + subtitle: panelSubtitle, + keywords: ["tab", "pin", "pinned"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleTabUnread", + title: { context in + context.bool(CommandPaletteContextKeys.panelHasUnread) ? String(localized: "command.markTabRead.title", defaultValue: "Mark Tab as Read") : String(localized: "command.markTabUnread.title", defaultValue: "Mark Tab as Unread") + }, + subtitle: panelSubtitle, + keywords: ["tab", "read", "unread", "notification"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.nextTabInPane", + title: constant(String(localized: "command.nextTabInPane.title", defaultValue: "Next Tab in Pane")), + subtitle: constant(String(localized: "command.nextTabInPane.subtitle", defaultValue: "Tab Navigation")), + keywords: ["next", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.previousTabInPane", + title: constant(String(localized: "command.previousTabInPane.title", defaultValue: "Previous Tab in Pane")), + subtitle: constant(String(localized: "command.previousTabInPane.subtitle", defaultValue: "Tab Navigation")), + keywords: ["previous", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.openWorkspacePullRequests", + title: constant(String(localized: "command.openWorkspacePRLinks.title", defaultValue: "Open All Workspace PR Links")), + subtitle: workspaceSubtitle, + keywords: ["pull", "request", "review", "merge", "pr", "mr", "open", "links", "workspace"], + when: { + $0.bool(CommandPaletteContextKeys.hasWorkspace) && + $0.bool(CommandPaletteContextKeys.workspaceHasPullRequests) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserBack", + title: constant(String(localized: "command.browserBack.title", defaultValue: "Back")), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘[", + keywords: ["browser", "back", "history"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserForward", + title: constant(String(localized: "command.browserForward.title", defaultValue: "Forward")), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘]", + keywords: ["browser", "forward", "history"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserReload", + title: constant(String(localized: "command.browserReload.title", defaultValue: "Reload Page")), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘R", + keywords: ["browser", "reload", "refresh"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserOpenDefault", + title: constant(String(localized: "command.browserOpenDefault.title", defaultValue: "Open Current Page in Default Browser")), + subtitle: browserPanelSubtitle, + keywords: ["open", "default", "external", "browser"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserFocusAddressBar", + title: constant(String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar")), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘L", + keywords: ["browser", "address", "omnibar", "url"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserToggleDevTools", + title: constant(String(localized: "command.browserToggleDevTools.title", defaultValue: "Toggle Developer Tools")), + subtitle: browserPanelSubtitle, + keywords: ["browser", "devtools", "inspector"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserConsole", + title: constant(String(localized: "command.browserConsole.title", defaultValue: "Show JavaScript Console")), + subtitle: browserPanelSubtitle, + keywords: ["browser", "console", "javascript"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomIn", + title: constant(String(localized: "command.browserZoomIn.title", defaultValue: "Zoom In")), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "in"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomOut", + title: constant(String(localized: "command.browserZoomOut.title", defaultValue: "Zoom Out")), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "out"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomReset", + title: constant(String(localized: "command.browserZoomReset.title", defaultValue: "Actual Size")), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "reset", "actual size"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserClearHistory", + title: constant(String(localized: "command.browserClearHistory.title", defaultValue: "Clear Browser History")), + subtitle: constant(String(localized: "command.browserClearHistory.subtitle", defaultValue: "Browser")), + keywords: ["browser", "history", "clear"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserSplitRight", + title: constant(String(localized: "command.browserSplitRight.title", defaultValue: "Split Browser Right")), + subtitle: constant(String(localized: "command.browserSplitRight.subtitle", defaultValue: "Browser Layout")), + keywords: ["browser", "split", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserSplitDown", + title: constant(String(localized: "command.browserSplitDown.title", defaultValue: "Split Browser Down")), + subtitle: constant(String(localized: "command.browserSplitDown.subtitle", defaultValue: "Browser Layout")), + keywords: ["browser", "split", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserDuplicateRight", + title: constant(String(localized: "command.browserDuplicateRight.title", defaultValue: "Duplicate Browser to the Right")), + subtitle: constant(String(localized: "command.browserDuplicateRight.subtitle", defaultValue: "Browser Layout")), + keywords: ["browser", "duplicate", "clone", "split"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + contributions.append( + CommandPaletteCommandContribution( + commandId: target.commandPaletteCommandId, + title: constant(target.commandPaletteTitle), + subtitle: terminalPanelSubtitle, + keywords: target.commandPaletteKeywords, + when: { context in + context.bool(CommandPaletteContextKeys.panelIsTerminal) + && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(target)) + } + ) + ) + } + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.vscodeServeWebStop", + title: constant(String(localized: "command.vscodeServeWebStop.title", defaultValue: "Stop VS Code Inline Server")), + subtitle: terminalPanelSubtitle, + keywords: ["vscode", "inline", "serve-web", "stop", "server"], + when: { context in + context.bool(CommandPaletteContextKeys.panelIsTerminal) + && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode)) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.vscodeServeWebRestart", + title: constant(String(localized: "command.vscodeServeWebRestart.title", defaultValue: "Restart VS Code Inline Server")), + subtitle: terminalPanelSubtitle, + keywords: ["vscode", "inline", "serve-web", "restart", "server"], + when: { context in + context.bool(CommandPaletteContextKeys.panelIsTerminal) + && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode)) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFind", + title: constant(String(localized: "command.terminalFind.title", defaultValue: "Find…")), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘F", + keywords: ["terminal", "find", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFindNext", + title: constant(String(localized: "command.terminalFindNext.title", defaultValue: "Find Next")), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘G", + keywords: ["terminal", "find", "next", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFindPrevious", + title: constant(String(localized: "command.terminalFindPrevious.title", defaultValue: "Find Previous")), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘⇧G", + keywords: ["terminal", "find", "previous", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalHideFind", + title: constant(String(localized: "command.terminalHideFind.title", defaultValue: "Hide Find Bar")), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘⇧F", + keywords: ["terminal", "hide", "find", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalUseSelectionForFind", + title: constant(String(localized: "command.terminalUseSelectionForFind.title", defaultValue: "Use Selection for Find")), + subtitle: terminalPanelSubtitle, + keywords: ["terminal", "selection", "find"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitRight", + title: constant(String(localized: "command.terminalSplitRight.title", defaultValue: "Split Right")), + subtitle: constant(String(localized: "command.terminalSplitRight.subtitle", defaultValue: "Terminal Layout")), + keywords: ["terminal", "split", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitDown", + title: constant(String(localized: "command.terminalSplitDown.title", defaultValue: "Split Down")), + subtitle: constant(String(localized: "command.terminalSplitDown.subtitle", defaultValue: "Terminal Layout")), + keywords: ["terminal", "split", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitBrowserRight", + title: constant(String(localized: "command.terminalSplitBrowserRight.title", defaultValue: "Split Browser Right")), + subtitle: constant(String(localized: "command.terminalSplitBrowserRight.subtitle", defaultValue: "Terminal Layout")), + keywords: ["terminal", "split", "browser", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitBrowserDown", + title: constant(String(localized: "command.terminalSplitBrowserDown.title", defaultValue: "Split Browser Down")), + subtitle: constant(String(localized: "command.terminalSplitBrowserDown.subtitle", defaultValue: "Terminal Layout")), + keywords: ["terminal", "split", "browser", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleSplitZoom", + title: constant(String(localized: "command.toggleSplitZoom.title", defaultValue: "Toggle Pane Zoom")), + subtitle: constant(String(localized: "command.toggleSplitZoom.subtitle", defaultValue: "Terminal Layout")), + keywords: ["terminal", "pane", "split", "zoom", "maximize"], + when: { context in + context.bool(CommandPaletteContextKeys.panelIsTerminal) && + context.bool(CommandPaletteContextKeys.workspaceHasSplits) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.equalizeSplits", + title: constant(String(localized: "command.equalizeSplits.title", defaultValue: "Equalize Splits")), + subtitle: workspaceSubtitle, + keywords: ["split", "equalize", "balance", "divider", "layout"], + when: { $0.bool(CommandPaletteContextKeys.workspaceHasSplits) } + ) + ) + + return contributions + } + + private func registerCommandPaletteHandlers(_ registry: inout CommandPaletteHandlerRegistry) { + registry.register(commandId: "palette.newWorkspace") { + tabManager.addWorkspace() + } + registry.register(commandId: "palette.openFolder") { + // Defer so the command palette dismisses before the modal sheet appears. + DispatchQueue.main.async { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.title = String(localized: "panel.openFolder.title", defaultValue: "Open Folder") + panel.prompt = String(localized: "panel.openFolder.prompt", defaultValue: "Open") + if panel.runModal() == .OK, let url = panel.url { + tabManager.addWorkspace(workingDirectory: url.path) + } + } + } + registry.register(commandId: "palette.newWindow") { + AppDelegate.shared?.openNewMainWindow(nil) + } + registry.register(commandId: "palette.installCLI") { + AppDelegate.shared?.installCmuxCLIInPath(nil) + } + registry.register(commandId: "palette.uninstallCLI") { + AppDelegate.shared?.uninstallCmuxCLIInPath(nil) + } + registry.register(commandId: "palette.newTerminalTab") { + tabManager.newSurface() + } + registry.register(commandId: "palette.newBrowserTab") { + // Let command-palette dismissal complete first so omnibar focus + // is not blocked by the palette visibility guard. + DispatchQueue.main.async { + _ = AppDelegate.shared?.openBrowserAndFocusAddressBar() + } + } + registry.register(commandId: "palette.closeTab") { + tabManager.closeCurrentPanelWithConfirmation() + } + registry.register(commandId: "palette.closeWorkspace") { + tabManager.closeCurrentWorkspaceWithConfirmation() + } + registry.register(commandId: "palette.closeWindow") { + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return + } + if let appDelegate = AppDelegate.shared { + appDelegate.closeWindowWithConfirmation(window) + } else { + window.performClose(nil) + } + } + registry.register(commandId: "palette.toggleFullScreen") { + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return + } + window.toggleFullScreen(nil) + } + registry.register(commandId: "palette.reopenClosedBrowserTab") { + _ = tabManager.reopenMostRecentlyClosedBrowserPanel() + } + registry.register(commandId: "palette.toggleSidebar") { + sidebarState.toggle() + } + registry.register(commandId: "palette.triggerFlash") { + tabManager.triggerFocusFlash() + } + registry.register(commandId: "palette.showNotifications") { + AppDelegate.shared?.toggleNotificationsPopover(animated: false) + } + registry.register(commandId: "palette.jumpUnread") { + AppDelegate.shared?.jumpToLatestUnread() + } + registry.register(commandId: "palette.openSettings") { +#if DEBUG + dlog("palette.openSettings.invoke") +#endif + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow(debugSource: "palette.openSettings") + } else { +#if DEBUG + dlog("palette.openSettings.missingAppDelegate fallback=1") +#endif + AppDelegate.presentPreferencesWindow() + } + } + registry.register(commandId: "palette.checkForUpdates") { + AppDelegate.shared?.checkForUpdates(nil) + } + registry.register(commandId: "palette.applyUpdateIfAvailable") { + AppDelegate.shared?.applyUpdateIfAvailable(nil) + } + registry.register(commandId: "palette.attemptUpdate") { + AppDelegate.shared?.attemptUpdate(nil) + } + registry.register(commandId: "palette.restartSocketListener") { + AppDelegate.shared?.restartSocketListener(nil) + } + + registry.register(commandId: "palette.renameWorkspace") { + beginRenameWorkspaceFlow() + } + registry.register(commandId: "palette.clearWorkspaceName") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.clearCustomTitle(tabId: workspace.id) + } + registry.register(commandId: "palette.toggleWorkspacePin") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.setPinned(workspace, pinned: !workspace.isPinned) + } + registry.register(commandId: "palette.nextWorkspace") { + tabManager.selectNextTab() + } + registry.register(commandId: "palette.previousWorkspace") { + tabManager.selectPreviousTab() + } + registry.register(commandId: "palette.moveWorkspaceUp") { + moveSelectedWorkspace(by: -1) + } + registry.register(commandId: "palette.moveWorkspaceDown") { + moveSelectedWorkspace(by: 1) + } + registry.register(commandId: "palette.moveWorkspaceToTop") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.moveTabsToTop([workspace.id]) + tabManager.selectWorkspace(workspace) + } + registry.register(commandId: "palette.closeOtherWorkspaces") { + closeOtherSelectedWorkspaces() + } + registry.register(commandId: "palette.closeWorkspacesBelow") { + closeSelectedWorkspacesBelow() + } + registry.register(commandId: "palette.closeWorkspacesAbove") { + closeSelectedWorkspacesAbove() + } + registry.register(commandId: "palette.markWorkspaceRead") { + guard let workspaceId = tabManager.selectedWorkspace?.id else { + NSSound.beep() + return + } + notificationStore.markRead(forTabId: workspaceId) + } + registry.register(commandId: "palette.markWorkspaceUnread") { + guard let workspaceId = tabManager.selectedWorkspace?.id else { + NSSound.beep() + return + } + notificationStore.markUnread(forTabId: workspaceId) + } + + registry.register(commandId: "palette.renameTab") { + beginRenameTabFlow() + } + registry.register(commandId: "palette.clearTabName") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + panelContext.workspace.setPanelCustomTitle(panelId: panelContext.panelId, title: nil) + } + registry.register(commandId: "palette.toggleTabPin") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + panelContext.workspace.setPanelPinned( + panelId: panelContext.panelId, + pinned: !panelContext.workspace.isPanelPinned(panelContext.panelId) + ) + } + registry.register(commandId: "palette.toggleTabUnread") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + let hasUnread = panelContext.workspace.manualUnreadPanelIds.contains(panelContext.panelId) + || notificationStore.hasUnreadNotification(forTabId: panelContext.workspace.id, surfaceId: panelContext.panelId) + if hasUnread { + panelContext.workspace.markPanelRead(panelContext.panelId) + } else { + panelContext.workspace.markPanelUnread(panelContext.panelId) + } + } + registry.register(commandId: "palette.nextTabInPane") { + tabManager.selectNextSurface() + } + registry.register(commandId: "palette.previousTabInPane") { + tabManager.selectPreviousSurface() + } + registry.register(commandId: "palette.openWorkspacePullRequests") { + DispatchQueue.main.async { + if !openWorkspacePullRequestsInConfiguredBrowser() { + NSSound.beep() + } + } + } + + registry.register(commandId: "palette.browserBack") { + tabManager.focusedBrowserPanel?.goBack() + } + registry.register(commandId: "palette.browserForward") { + tabManager.focusedBrowserPanel?.goForward() + } + registry.register(commandId: "palette.browserReload") { + tabManager.focusedBrowserPanel?.reload() + } + registry.register(commandId: "palette.browserOpenDefault") { + if !openFocusedBrowserInDefaultBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserFocusAddressBar") { + if !focusFocusedBrowserAddressBar() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserToggleDevTools") { + if !tabManager.toggleDeveloperToolsFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserConsole") { + if !tabManager.showJavaScriptConsoleFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomIn") { + if !tabManager.zoomInFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomOut") { + if !tabManager.zoomOutFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomReset") { + if !tabManager.resetZoomFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserClearHistory") { + BrowserHistoryStore.shared.clearHistory() + } + registry.register(commandId: "palette.browserSplitRight") { + _ = tabManager.createBrowserSplit(direction: .right) + } + registry.register(commandId: "palette.browserSplitDown") { + _ = tabManager.createBrowserSplit(direction: .down) + } + registry.register(commandId: "palette.browserDuplicateRight") { + let url = tabManager.focusedBrowserPanel?.preferredURLStringForOmnibar().flatMap(URL.init(string:)) + _ = tabManager.createBrowserSplit(direction: .right, url: url) + } + + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + registry.register(commandId: target.commandPaletteCommandId) { + if !openFocusedDirectory(in: target) { + NSSound.beep() + } + } + } + registry.register(commandId: "palette.vscodeServeWebStop") { + stopInlineVSCodeServeWeb() + } + registry.register(commandId: "palette.vscodeServeWebRestart") { + if !restartInlineVSCodeServeWeb() { + NSSound.beep() + } + } + registry.register(commandId: "palette.terminalFind") { + tabManager.startSearch() + } + registry.register(commandId: "palette.terminalFindNext") { + tabManager.findNext() + } + registry.register(commandId: "palette.terminalFindPrevious") { + tabManager.findPrevious() + } + registry.register(commandId: "palette.terminalHideFind") { + tabManager.hideFind() + } + registry.register(commandId: "palette.terminalUseSelectionForFind") { + tabManager.searchSelection() + } + registry.register(commandId: "palette.terminalSplitRight") { + tabManager.createSplit(direction: .right) + } + registry.register(commandId: "palette.terminalSplitDown") { + tabManager.createSplit(direction: .down) + } + registry.register(commandId: "palette.terminalSplitBrowserRight") { + _ = tabManager.createBrowserSplit(direction: .right) + } + registry.register(commandId: "palette.terminalSplitBrowserDown") { + _ = tabManager.createBrowserSplit(direction: .down) + } + registry.register(commandId: "palette.toggleSplitZoom") { + if !tabManager.toggleFocusedSplitZoom() { + NSSound.beep() + } + } + registry.register(commandId: "palette.equalizeSplits") { + guard let workspace = tabManager.selectedWorkspace, + tabManager.equalizeSplits(tabId: workspace.id) else { + NSSound.beep() + return + } + } + } + + private var focusedPanelContext: (workspace: Workspace, panelId: UUID, panel: any Panel)? { + guard let workspace = tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let panel = workspace.panels[panelId] else { + return nil + } + return (workspace, panelId, panel) + } + + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let custom = workspace.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !custom.isEmpty { + return custom + } + let title = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : title + } + + private func panelDisplayName(workspace: Workspace, panelId: UUID, fallback: String) -> String { + let title = workspace.panelTitle(panelId: panelId)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + return title + } + let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedFallback.isEmpty ? String(localized: "panel.displayName.fallback", defaultValue: "Tab") : trimmedFallback + } + + private func commandPaletteSelectedIndex(resultCount: Int) -> Int { + guard resultCount > 0 else { return 0 } + return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1) + } + + static func commandPaletteResolvedSelectionIndex( + preferredCommandID: String?, + fallbackSelectedIndex: Int, + resultIDs: [String] + ) -> Int { + guard !resultIDs.isEmpty else { return 0 } + if let preferredCommandID, + let anchoredIndex = resultIDs.firstIndex(of: preferredCommandID) { + return anchoredIndex + } + return min(max(fallbackSelectedIndex, 0), resultIDs.count - 1) + } + + static func commandPaletteSelectionAnchorCommandID( + selectedIndex: Int, + resultIDs: [String] + ) -> String? { + guard !resultIDs.isEmpty else { return nil } + let resolvedIndex = min(max(selectedIndex, 0), resultIDs.count - 1) + return resultIDs[resolvedIndex] + } + + static func commandPalettePendingActivationRequestID( + _ pendingActivation: CommandPalettePendingActivation? + ) -> UInt64? { + switch pendingActivation { + case .selected(let requestID, _, _): + return requestID + case .command(let requestID, _): + return requestID + case nil: + return nil + } + } + + static func commandPaletteResolvedPendingActivation( + _ pendingActivation: CommandPalettePendingActivation?, + requestID: UInt64, + resultIDs: [String] + ) -> CommandPaletteResolvedActivation? { + switch pendingActivation { + case .selected(let activationRequestID, let fallbackSelectedIndex, let preferredCommandID): + guard activationRequestID == requestID else { return nil } + let resolvedIndex = commandPaletteResolvedSelectionIndex( + preferredCommandID: preferredCommandID, + fallbackSelectedIndex: fallbackSelectedIndex, + resultIDs: resultIDs + ) + return .selected(index: resolvedIndex) + case .command(let activationRequestID, let commandID): + guard activationRequestID == requestID, resultIDs.contains(commandID) else { return nil } + return .command(commandID: commandID) + case nil: + return nil + } + } + + static func commandPaletteContextFingerprint( + boolValues: [String: Bool], + stringValues: [String: String] + ) -> Int { + var hasher = Hasher() + for key in boolValues.keys.sorted() { + hasher.combine(key) + hasher.combine(boolValues[key] ?? false) + } + for key in stringValues.keys.sorted() { + hasher.combine(key) + hasher.combine(stringValues[key] ?? "") + } + return hasher.finalize() + } + + static func commandPaletteSwitcherFingerprint( + windowContexts: [CommandPaletteSwitcherFingerprintContext] + ) -> Int { + var hasher = Hasher() + hasher.combine(windowContexts.count) + for context in windowContexts { + hasher.combine(context.windowId) + hasher.combine(context.windowLabel) + hasher.combine(context.selectedWorkspaceId) + hasher.combine(context.workspaces.count) + for workspace in context.workspaces { + hasher.combine(workspace.id) + hasher.combine(workspace.displayName) + combineCommandPaletteSwitcherSearchMetadata(workspace.metadata, into: &hasher) + } + } + return hasher.finalize() + } + + static func combineCommandPaletteSwitcherSearchMetadata( + _ metadata: CommandPaletteSwitcherSearchMetadata, + into hasher: inout Hasher + ) { + hasher.combine(metadata.directories.count) + for directory in metadata.directories { + hasher.combine(directory) + } + hasher.combine(metadata.branches.count) + for branch in metadata.branches { + hasher.combine(branch) + } + hasher.combine(metadata.ports.count) + for port in metadata.ports { + hasher.combine(port) + } + } + + static func commandPaletteScrollPositionAnchor( + selectedIndex: Int, + resultCount: Int + ) -> UnitPoint? { + guard resultCount > 0 else { return nil } + if selectedIndex <= 0 { + return UnitPoint.top + } + if selectedIndex >= resultCount - 1 { + return UnitPoint.bottom + } + return nil + } + + private func updateCommandPaletteScrollTarget(resultCount: Int, animated: Bool) { + guard resultCount > 0 else { + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + return + } + + let selectedIndex = commandPaletteSelectedIndex(resultCount: resultCount) + commandPaletteScrollTargetAnchor = Self.commandPaletteScrollPositionAnchor( + selectedIndex: selectedIndex, + resultCount: resultCount + ) + + let assignTarget = { + commandPaletteScrollTargetIndex = selectedIndex + } + if animated { + withAnimation(.easeOut(duration: 0.1)) { + assignTarget() + } + } else { + assignTarget() + } + } + + private func syncCommandPaletteSelectionAnchor(resultIDs: [String]) { + commandPaletteSelectionAnchorCommandID = Self.commandPaletteSelectionAnchorCommandID( + selectedIndex: commandPaletteSelectedResultIndex, + resultIDs: resultIDs + ) + } + + private func syncCommandPaletteSelectionAnchorFromCurrentResults() { + syncCommandPaletteSelectionAnchor(resultIDs: cachedCommandPaletteResults.map(\.id)) + } + + private func syncCommandPaletteSelectionAnchorFromVisibleResults() { + syncCommandPaletteSelectionAnchor(resultIDs: commandPaletteVisibleResults.map(\.id)) + } + + private func moveCommandPaletteSelection(by delta: Int) { + let count = commandPaletteVisibleResults.count + guard count > 0 else { + NSSound.beep() + return + } + let current = commandPaletteSelectedIndex(resultCount: count) + commandPaletteSelectedResultIndex = min(max(current + delta, 0), count - 1) + if commandPaletteHasCurrentResolvedResults { + syncCommandPaletteSelectionAnchorFromCurrentResults() + } else { + syncCommandPaletteSelectionAnchorFromVisibleResults() + } + syncCommandPaletteDebugStateForObservedWindow() + } + + private func handleCommandPaletteControlNavigationKey( + modifiers: EventModifiers, + delta: Int + ) -> BackportKeyPressResult { + guard modifiers.contains(.control), + !modifiers.contains(.command), + !modifiers.contains(.shift), + !modifiers.contains(.option) else { + return .ignored + } + moveCommandPaletteSelection(by: delta) + return .handled + } + + static func commandPaletteShouldPopRenameInputOnDelete( + renameDraft: String, + modifiers: EventModifiers + ) -> Bool { + let blockedModifiers: EventModifiers = [.command, .control, .option, .shift] + guard modifiers.intersection(blockedModifiers).isEmpty else { return false } + return renameDraft.isEmpty + } + + private func handleCommandPaletteRenameDeleteBackward( + modifiers: EventModifiers + ) -> BackportKeyPressResult { + guard case .renameInput = commandPaletteMode else { return .ignored } + let blockedModifiers: EventModifiers = [.command, .control, .option, .shift] + guard modifiers.intersection(blockedModifiers).isEmpty else { return .ignored } + + if Self.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: commandPaletteRenameDraft, + modifiers: modifiers + ) { + commandPaletteMode = .commands + resetCommandPaletteSearchFocus() + syncCommandPaletteDebugStateForObservedWindow() + return .handled + } + + if let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow, + let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor { + editor.deleteBackward(nil) + commandPaletteRenameDraft = editor.string + } else if !commandPaletteRenameDraft.isEmpty { + commandPaletteRenameDraft.removeLast() + } + + syncCommandPaletteDebugStateForObservedWindow() + return .handled + } + + private var commandPaletteHasCurrentResolvedResults: Bool { + !isCommandPaletteSearchPending && commandPaletteResolvedSearchRequestID == commandPaletteSearchRequestID + } + + private func runCommandPaletteResolvedActivation(_ activation: CommandPaletteResolvedActivation) { + switch activation { + case .command(let commandID): + guard let command = cachedCommandPaletteResults.first(where: { $0.id == commandID })?.command else { + return + } + runCommandPaletteCommand(command) + case .selected(let fallbackIndex): + guard !cachedCommandPaletteResults.isEmpty else { + NSSound.beep() + return + } + let resolvedIndex = Self.commandPaletteResolvedSelectionIndex( + preferredCommandID: commandPaletteSelectionAnchorCommandID, + fallbackSelectedIndex: fallbackIndex, + resultIDs: cachedCommandPaletteResults.map(\.id) + ) + commandPaletteSelectedResultIndex = resolvedIndex + syncCommandPaletteSelectionAnchorFromCurrentResults() + runCommandPaletteCommand(cachedCommandPaletteResults[resolvedIndex].command) + } + } + + private func runCommandPaletteResult(commandID: String) { + guard commandPaletteHasCurrentResolvedResults else { + if isCommandPalettePresented { + commandPalettePendingActivation = .command( + requestID: commandPaletteSearchRequestID, + commandID: commandID + ) + } + return + } + runCommandPaletteResolvedActivation(.command(commandID: commandID)) + } + + private func runSelectedCommandPaletteResult() { + guard commandPaletteHasCurrentResolvedResults else { + if isCommandPalettePresented { + commandPalettePendingActivation = .selected( + requestID: commandPaletteSearchRequestID, + fallbackSelectedIndex: commandPaletteSelectedResultIndex, + preferredCommandID: commandPaletteSelectionAnchorCommandID + ) + } + return + } + + runCommandPaletteResolvedActivation(.selected(index: commandPaletteSelectedResultIndex)) + } + + private func handleCommandPaletteSubmitRequest() { + switch commandPaletteMode { + case .commands: + runSelectedCommandPaletteResult() + case .renameInput(let target): + continueRenameFlow(target: target) + case .renameConfirm(let target, let proposedName): + applyRenameFlow(target: target, proposedName: proposedName) + } + } + + private func runCommandPaletteCommand(_ command: CommandPaletteCommand) { +#if DEBUG + dlog("palette.run commandId=\(command.id) dismissOnRun=\(command.dismissOnRun ? 1 : 0)") +#endif + recordCommandPaletteUsage(command.id) + command.action() + if command.dismissOnRun { + dismissCommandPalette(restoreFocus: false) + } + } + + private func toggleCommandPalette() { + if isCommandPalettePresented { + dismissCommandPalette() + } else { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + } + + private func openCommandPaletteCommands() { + toggleCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + + private func openCommandPaletteSwitcher() { + toggleCommandPalette(initialQuery: "") + } + + private func toggleCommandPalette(initialQuery: String) { + if isCommandPalettePresented { + dismissCommandPalette() + } else { + presentCommandPalette(initialQuery: initialQuery) + } + } + + private func openCommandPaletteRenameTabInput() { + if !isCommandPalettePresented { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + beginRenameTabFlow() + } + + private func openCommandPaletteRenameWorkspaceInput() { + if !isCommandPalettePresented { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + beginRenameWorkspaceFlow() + } + + private func presentFeedbackComposer() { + DispatchQueue.main.async { + isFeedbackComposerPresented = true + } + } + + static func shouldHandleCommandPaletteRequest( + observedWindow: NSWindow?, + requestedWindow: NSWindow?, + keyWindow: NSWindow?, + mainWindow: NSWindow? + ) -> Bool { + guard let observedWindow else { return false } + if let requestedWindow { + return requestedWindow === observedWindow + } + if let keyWindow { + return keyWindow === observedWindow + } + if let mainWindow { + return mainWindow === observedWindow + } + return false + } + + static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: Bool, + focusedBrowserAddressBarPanelId: UUID?, + focusedPanelId: UUID? + ) -> Bool { + focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId + } + + private func syncCommandPaletteDebugStateForObservedWindow() { + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } + AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window) + let visibleResultCount = commandPaletteVisibleResults.count + let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0 + AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window) + AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window) + } + + private func commandPaletteDebugSnapshot() -> CommandPaletteDebugSnapshot { + guard isCommandPalettePresented else { return .empty } + + let mode: String + switch commandPaletteMode { + case .commands: + mode = commandPaletteListScope.rawValue + case .renameInput: + mode = "rename_input" + case .renameConfirm: + mode = "rename_confirm" + } + + let rows = Array(commandPaletteVisibleResults.prefix(20)).map { result in + CommandPaletteDebugResultRow( + commandId: result.command.id, + title: result.command.title, + shortcutHint: result.command.shortcutHint, + trailingLabel: commandPaletteTrailingLabel(for: result.command)?.text, + score: result.score + ) + } + + return CommandPaletteDebugSnapshot( + query: commandPaletteQueryForMatching, + mode: mode, + results: rows + ) + } + + private func presentCommandPalette(initialQuery: String) { + if let panelContext = focusedPanelContext { + commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget( + workspaceId: panelContext.workspace.id, + panelId: panelContext.panelId, + intent: panelContext.panel.captureFocusIntent(in: observedWindow) + ) + } else { + commandPaletteRestoreFocusTarget = nil + } + isCommandPalettePresented = true + refreshCommandPaletteUsageHistory() + resetCommandPaletteListState(initialQuery: initialQuery) + } + + private func resetCommandPaletteListState(initialQuery: String) { + commandPaletteMode = .commands + commandPaletteQuery = initialQuery + commandPaletteRenameDraft = "" + commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil + commandPaletteHoveredResultIndex = nil + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) + resetCommandPaletteSearchFocus() + syncCommandPaletteDebugStateForObservedWindow() + } + + private func dismissCommandPalette(restoreFocus: Bool = true) { + dismissCommandPalette(restoreFocus: restoreFocus, preferredFocusTarget: nil) + } + + private func dismissCommandPalette( + restoreFocus: Bool, + preferredFocusTarget: CommandPaletteRestoreFocusTarget? + ) { + let focusTarget = preferredFocusTarget ?? commandPaletteRestoreFocusTarget + cancelCommandPaletteSearch() + commandPaletteSearchRequestID &+= 1 + isCommandPalettePresented = false + commandPaletteMode = .commands + commandPaletteQuery = "" + commandPaletteRenameDraft = "" + commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil + commandPaletteHoveredResultIndex = nil + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + isCommandPaletteSearchFocused = false + isCommandPaletteRenameFocused = false + commandPaletteRestoreFocusTarget = nil + commandPaletteSearchCorpus = [] + commandPaletteSearchCorpusByID = [:] + commandPaletteSearchCommandsByID = [:] + cachedCommandPaletteResults = [] + commandPaletteVisibleResults = [] + commandPaletteVisibleResultsScope = nil + commandPaletteVisibleResultsFingerprint = nil + cachedCommandPaletteScope = nil + cachedCommandPaletteFingerprint = nil + commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID + commandPaletteResolvedSearchScope = nil + commandPaletteResolvedSearchFingerprint = nil + isCommandPaletteSearchPending = false + commandPalettePendingActivation = nil + commandPaletteResultsRevision &+= 1 + if let window = observedWindow { + _ = window.makeFirstResponder(nil) + } + syncCommandPaletteDebugStateForObservedWindow() + + guard restoreFocus, let focusTarget else { return } + restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6) + } + + private func handleCommandPaletteBackdropClick(atContentPoint contentPoint: CGPoint) { + let clickedFocusTarget = commandPaletteBackdropFocusTarget(atContentPoint: contentPoint) +#if DEBUG + if let clickedFocusTarget { + dlog( + "palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " + + "workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(debugCommandPaletteFocusIntent(clickedFocusTarget.intent))" + ) + } else { + dlog("palette.dismiss.backdrop focusTarget=nil") + } +#endif + dismissCommandPalette(restoreFocus: true, preferredFocusTarget: clickedFocusTarget) + } + + private func commandPaletteBackdropFocusTarget(atContentPoint contentPoint: CGPoint) -> CommandPaletteRestoreFocusTarget? { + guard let window = observedWindow, + let contentView = window.contentView else { + return nil + } + + let nsContentPoint = NSPoint(x: contentPoint.x, y: contentPoint.y) + let windowPoint = contentView.convert(nsContentPoint, to: nil) + return commandPaletteBackdropFocusTarget(atWindowPoint: windowPoint, in: window) + } + + private func commandPaletteBackdropFocusTarget( + atWindowPoint windowPoint: NSPoint, + in window: NSWindow + ) -> CommandPaletteRestoreFocusTarget? { + let overlayController = commandPaletteWindowOverlayController(for: window) + if let responder = overlayController.underlyingResponder(atWindowPoint: windowPoint), + let target = commandPaletteBackdropFocusTarget(for: responder) { + return target + } + + if let webView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window), + let target = commandPaletteBrowserFocusTarget(for: webView) { + return target + } + + if let terminalView = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window), + let workspaceId = terminalView.tabId, + let panelId = terminalView.terminalSurface?.id, + tabManager.tabs.contains(where: { $0.id == workspaceId }) { + return commandPaletteRestoreFocusTarget( + workspaceId: workspaceId, + panelId: panelId, + fallbackIntent: .terminal(.surface), + in: window + ) + } + + return nil + } + + private func commandPaletteBackdropFocusTarget(for responder: NSResponder) -> CommandPaletteRestoreFocusTarget? { + if let terminalView = cmuxOwningGhosttyView(for: responder), + let workspaceId = terminalView.tabId, + let panelId = terminalView.terminalSurface?.id, + tabManager.tabs.contains(where: { $0.id == workspaceId }) { + return commandPaletteRestoreFocusTarget( + workspaceId: workspaceId, + panelId: panelId, + fallbackIntent: .terminal(.surface), + in: observedWindow + ) + } + + if let webView = commandPaletteOwningWebView(for: responder), + let target = commandPaletteBrowserFocusTarget(for: webView) { + return target + } + + return nil + } + + private func commandPaletteBrowserFocusTarget(for webView: WKWebView) -> CommandPaletteRestoreFocusTarget? { + if let selectedWorkspace = tabManager.selectedWorkspace, + let target = commandPaletteBrowserFocusTarget(in: selectedWorkspace, for: webView) { + return target + } + + let selectedWorkspaceId = tabManager.selectedTabId + for workspace in tabManager.tabs where workspace.id != selectedWorkspaceId { + if let target = commandPaletteBrowserFocusTarget(in: workspace, for: webView) { + return target + } + } + + return nil + } + + private func commandPaletteBrowserFocusTarget( + in workspace: Workspace, + for webView: WKWebView + ) -> CommandPaletteRestoreFocusTarget? { + for (panelId, panel) in workspace.panels { + guard let browserPanel = panel as? BrowserPanel, + browserPanel.webView === webView else { + continue + } + + return commandPaletteRestoreFocusTarget( + workspaceId: workspace.id, + panelId: panelId, + fallbackIntent: .browser(.webView), + in: observedWindow + ) + } + + return nil + } + + private func commandPaletteRestoreFocusTarget( + workspaceId: UUID, + panelId: UUID, + fallbackIntent: PanelFocusIntent, + in window: NSWindow? + ) -> CommandPaletteRestoreFocusTarget { + let intent = tabManager.tabs + .first(where: { $0.id == workspaceId })? + .panels[panelId]? + .captureFocusIntent(in: window) ?? fallbackIntent + + return CommandPaletteRestoreFocusTarget( + workspaceId: workspaceId, + panelId: panelId, + intent: intent + ) + } + + private func restoreCommandPaletteFocus( + target: CommandPaletteRestoreFocusTarget, + attemptsRemaining: Int + ) { + guard !isCommandPalettePresented else { return } + guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return } + + if let window = observedWindow, !window.isKeyWindow { + window.makeKeyAndOrderFront(nil) + } + tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true) + + if let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId { + if context.panel.restoreFocusIntent(target.intent) { + return + } + } + + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + guard !isCommandPalettePresented else { return } + if let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId { + if context.panel.restoreFocusIntent(target.intent) { + return + } + } + restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1) + } + } + +#if DEBUG + private func debugCommandPaletteFocusIntent(_ intent: PanelFocusIntent) -> String { + switch intent { + case .panel: + return "panel" + case .terminal(.surface): + return "terminal.surface" + case .terminal(.findField): + return "terminal.findField" + case .browser(.webView): + return "browser.webView" + case .browser(.addressBar): + return "browser.addressBar" + case .browser(.findField): + return "browser.findField" + } + } +#endif + + private func resetCommandPaletteSearchFocus() { + applyCommandPaletteInputFocusPolicy(.search) + } + + private func resetCommandPaletteRenameFocus() { + applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy()) + } + + private func handleCommandPaletteRenameInputInteraction() { + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy()) + } + + private func commandPaletteRenameInputFocusPolicy() -> CommandPaletteInputFocusPolicy { + let selectAllOnFocus = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() + let selectionBehavior: CommandPaletteTextSelectionBehavior = selectAllOnFocus + ? .selectAll + : .caretAtEnd + return CommandPaletteInputFocusPolicy( + focusTarget: .rename, + selectionBehavior: selectionBehavior + ) + } + + private func applyCommandPaletteInputFocusPolicy(_ policy: CommandPaletteInputFocusPolicy) { + DispatchQueue.main.async { + switch policy.focusTarget { + case .search: + isCommandPaletteRenameFocused = false + isCommandPaletteSearchFocused = true + case .rename: + isCommandPaletteSearchFocused = false + isCommandPaletteRenameFocused = true + } + applyCommandPaletteTextSelection(policy.selectionBehavior) + } + } + + private func applyCommandPaletteTextSelection( + _ behavior: CommandPaletteTextSelectionBehavior, + attemptsRemaining: Int = 20 + ) { + guard isCommandPalettePresented else { return } + switch behavior { + case .selectAll: + guard case .renameInput = commandPaletteMode else { return } + case .caretAtEnd: + switch commandPaletteMode { + case .commands, .renameInput: + break + case .renameConfirm: + return + } + } + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } + + if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor { + let length = (editor.string as NSString).length + switch behavior { + case .selectAll: + editor.setSelectedRange(NSRange(location: 0, length: length)) + case .caretAtEnd: + editor.setSelectedRange(NSRange(location: length, length: 0)) + } + return + } + + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1) + } + } + + private func refreshCommandPaletteUsageHistory() { + commandPaletteUsageHistoryByCommandId = loadCommandPaletteUsageHistory() + } + + private func loadCommandPaletteUsageHistory() -> [String: CommandPaletteUsageEntry] { + guard let data = UserDefaults.standard.data(forKey: Self.commandPaletteUsageDefaultsKey) else { + return [:] + } + return (try? JSONDecoder().decode([String: CommandPaletteUsageEntry].self, from: data)) ?? [:] + } + + private func persistCommandPaletteUsageHistory(_ history: [String: CommandPaletteUsageEntry]) { + guard let data = try? JSONEncoder().encode(history) else { return } + UserDefaults.standard.set(data, forKey: Self.commandPaletteUsageDefaultsKey) + } + + private func recordCommandPaletteUsage(_ commandId: String) { + var history = commandPaletteUsageHistoryByCommandId + var entry = history[commandId] ?? CommandPaletteUsageEntry(useCount: 0, lastUsedAt: 0) + entry.useCount += 1 + entry.lastUsedAt = Date().timeIntervalSince1970 + history[commandId] = entry + commandPaletteUsageHistoryByCommandId = history + persistCommandPaletteUsageHistory(history) + } + + nonisolated private static func commandPaletteHistoryBoost( + for commandId: String, + queryIsEmpty: Bool, + history: [String: CommandPaletteUsageEntry], + now: TimeInterval + ) -> Int { + guard let entry = history[commandId] else { return 0 } + + let ageDays = max(0, now - entry.lastUsedAt) / 86_400 + let recencyBoost = max(0, 320 - Int(ageDays * 20)) + let countBoost = min(180, entry.useCount * 12) + let totalBoost = recencyBoost + countBoost + + return queryIsEmpty ? totalBoost : max(0, totalBoost / 3) + } + + private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { + Self.commandPaletteHistoryBoost( + for: commandId, + queryIsEmpty: queryIsEmpty, + history: commandPaletteUsageHistoryByCommandId, + now: Date().timeIntervalSince1970 + ) + } + + private func selectedWorkspaceIndex() -> Int? { + guard let workspace = tabManager.selectedWorkspace else { return nil } + return tabManager.tabs.firstIndex { $0.id == workspace.id } + } + + private func moveSelectedWorkspace(by delta: Int) { + guard let workspace = tabManager.selectedWorkspace, + let currentIndex = selectedWorkspaceIndex() else { return } + let targetIndex = currentIndex + delta + guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } + _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex) + tabManager.selectWorkspace(workspace) + } + + private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) { + for workspaceId in workspaceIds { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { continue } + guard allowPinned || !workspace.isPinned else { continue } + tabManager.closeWorkspaceWithConfirmation(workspace) + } + } + + private func closeOtherSelectedWorkspaces() { + guard let workspace = tabManager.selectedWorkspace else { return } + let workspaceIds = tabManager.tabs.compactMap { $0.id == workspace.id ? nil : $0.id } + closeWorkspaceIds(workspaceIds, allowPinned: false) + } + + private func closeSelectedWorkspacesBelow() { + guard let workspace = tabManager.selectedWorkspace, + let anchorIndex = selectedWorkspaceIndex() else { return } + let workspaceIds = tabManager.tabs.suffix(from: anchorIndex + 1).map(\.id) + closeWorkspaceIds(workspaceIds, allowPinned: false) + } + + private func closeSelectedWorkspacesAbove() { + guard let workspace = tabManager.selectedWorkspace, + let anchorIndex = selectedWorkspaceIndex() else { return } + let workspaceIds = tabManager.tabs.prefix(upTo: anchorIndex).map(\.id) + closeWorkspaceIds(workspaceIds, allowPinned: false) + } + + private func beginRenameWorkspaceFlow() { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + let target = CommandPaletteRenameTarget( + kind: .workspace(workspaceId: workspace.id), + currentName: workspaceDisplayName(workspace) + ) + startRenameFlow(target) + } + + private func beginRenameTabFlow() { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + let panelName = panelDisplayName( + workspace: panelContext.workspace, + panelId: panelContext.panelId, + fallback: panelContext.panel.displayTitle + ) + let target = CommandPaletteRenameTarget( + kind: .tab(workspaceId: panelContext.workspace.id, panelId: panelContext.panelId), + currentName: panelName + ) + startRenameFlow(target) + } + + private func startRenameFlow(_ target: CommandPaletteRenameTarget) { + commandPaletteRenameDraft = target.currentName + commandPaletteMode = .renameInput(target) + resetCommandPaletteRenameFocus() + syncCommandPaletteDebugStateForObservedWindow() + } + + private func continueRenameFlow(target: CommandPaletteRenameTarget) { + guard case .renameInput(let activeTarget) = commandPaletteMode, + activeTarget == target else { return } + applyRenameFlow(target: target, proposedName: commandPaletteRenameDraft) + } + + private func applyRenameFlow(target: CommandPaletteRenameTarget, proposedName: String) { + let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedName: String? = trimmedName.isEmpty ? nil : trimmedName + + switch target.kind { + case .workspace(let workspaceId): + tabManager.setCustomTitle(tabId: workspaceId, title: normalizedName) + case .tab(let workspaceId, let panelId): + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + NSSound.beep() + return + } + workspace.setPanelCustomTitle(panelId: panelId, title: normalizedName) + } + + dismissCommandPalette() + } + + private func focusFocusedBrowserAddressBar() -> Bool { + guard let panel = tabManager.focusedBrowserPanel else { return false } + _ = panel.requestAddressBarFocus() + NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) + return true + } + + private func openFocusedBrowserInDefaultBrowser() -> Bool { + guard let panel = tabManager.focusedBrowserPanel, + let rawURL = panel.preferredURLStringForOmnibar(), + let url = URL(string: rawURL), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return false + } + return NSWorkspace.shared.open(url) + } + + private func openWorkspacePullRequestsInConfiguredBrowser() -> Bool { + guard let workspace = tabManager.selectedWorkspace else { return false } + let pullRequests = workspace.sidebarPullRequestsInDisplayOrder() + guard !pullRequests.isEmpty else { return false } + + var openedCount = 0 + if openSidebarPullRequestLinksInCmuxBrowser { + for pullRequest in pullRequests { + if tabManager.openBrowser(url: pullRequest.url, insertAtEnd: true) != nil { + openedCount += 1 + } else if NSWorkspace.shared.open(pullRequest.url) { + openedCount += 1 + } + } + return openedCount > 0 + } + + for pullRequest in pullRequests { + if NSWorkspace.shared.open(pullRequest.url) { + openedCount += 1 + } + } + return openedCount > 0 + } + + private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool { + guard let directoryURL = focusedTerminalDirectoryURL() else { return false } + return openFocusedDirectory(directoryURL, in: target) + } + + private func openFocusedDirectory(_ directoryURL: URL, in target: TerminalDirectoryOpenTarget) -> Bool { + switch target { + case .finder: + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path) + return true + case .vscode: + return openFocusedDirectoryInInlineVSCode(directoryURL) + default: + guard let applicationURL = target.applicationURL() else { return false } + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([directoryURL], withApplicationAt: applicationURL, configuration: configuration) + return true + } + } + + private func openFocusedDirectoryInInlineVSCode(_ directoryURL: URL) -> Bool { + guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL(), + let workspace = tabManager.selectedWorkspace, + let sourcePanelId = workspace.focusedPanelId else { + return false + } + let sourceTabId = workspace.id + let tabManager = tabManager + VSCodeServeWebController.shared.ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in + guard let serveWebURL, + let openFolderURL = VSCodeServeWebURLBuilder.openFolderURL( + baseWebUIURL: serveWebURL, + directoryPath: directoryURL.path + ) else { + NSSound.beep() + return + } + guard tabManager.newBrowserSplit( + tabId: sourceTabId, + fromPanelId: sourcePanelId, + orientation: SplitDirection.right.orientation, + insertFirst: SplitDirection.right.insertFirst, + url: openFolderURL, + focus: true + ) != nil else { + NSSound.beep() + return + } + } + return true + } + + private func stopInlineVSCodeServeWeb() { + VSCodeServeWebController.shared.stop() + } + + private func restartInlineVSCodeServeWeb() -> Bool { + guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL() else { + return false + } + VSCodeServeWebController.shared.restart(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in + if serveWebURL == nil { + NSSound.beep() + } + } + return true + } + + private func focusedTerminalDirectoryURL() -> URL? { + guard let workspace = tabManager.selectedWorkspace else { return nil } + let rawDirectory: String = { + if let focusedPanelId = workspace.focusedPanelId, + let directory = workspace.panelDirectories[focusedPanelId] { + return directory + } + return workspace.currentDirectory + }() + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard FileManager.default.fileExists(atPath: trimmed) else { return nil } + return URL(fileURLWithPath: trimmed, isDirectory: true) + } + #if DEBUG private func debugShortWorkspaceId(_ id: UUID?) -> String { guard let id else { return "nil" } @@ -1700,6 +6192,950 @@ struct ContentView: View { #endif } +struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { + let directories: [String] + let branches: [String] + let ports: [Int] + + init( + directories: [String] = [], + branches: [String] = [], + ports: [Int] = [] + ) { + self.directories = directories + self.branches = branches + self.ports = ports + } +} + +enum CommandPaletteSwitcherSearchIndexer { + enum MetadataDetail { + case workspace + case surface + } + + private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") + + static func keywords( + baseKeywords: [String], + metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail = .surface + ) -> [String] { + let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) + return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) + } + + private static func metadataKeywordsForSearch( + _ metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail + ) -> [String] { + let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } + let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } + let portTokens = metadata.ports.flatMap(portTokensForSearch) + + var contextKeywords: [String] = [] + if !directoryTokens.isEmpty { + contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) + } + if !branchTokens.isEmpty { + contextKeywords.append(contentsOf: ["branch", "git"]) + } + if !portTokens.isEmpty { + contextKeywords.append(contentsOf: ["port", "ports"]) + } + + return contextKeywords + directoryTokens + branchTokens + portTokens + } + + private static func directoryTokensForSearch( + _ rawDirectory: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let standardized = (trimmed as NSString).standardizingPath + let canonical = standardized.isEmpty ? trimmed : standardized + let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath + switch detail { + case .workspace: + return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) + case .surface: + let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent + let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder( + [trimmed, canonical, abbreviated, basename] + components + ) + } + } + + private static func branchTokensForSearch( + _ rawBranch: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + switch detail { + case .workspace: + return [trimmed] + case .surface: + let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed] + components) + } + } + + private static func portTokensForSearch(_ port: Int) -> [String] { + guard (1...65535).contains(port) else { return [] } + let portText = String(port) + return [portText, ":\(portText)"] + } + + private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { + var result: [String] = [] + var seen: Set<String> = [] + result.reserveCapacity(values.count) + + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let normalizedKey = trimmed + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + guard seen.insert(normalizedKey).inserted else { continue } + result.append(trimmed) + } + return result + } +} + +enum CommandPaletteFuzzyMatcher { + private static let tokenBoundaryChars: Set<Character> = [" ", "-", "_", "/", ".", ":"] + + private enum SingleEditWordPrefixEditKind { + case candidateExtraCharacter + case tokenExtraCharacter + case substitutedCharacter + case transposedCharacters + + var basePenalty: Int { + switch self { + case .candidateExtraCharacter: + return 0 + case .tokenExtraCharacter: + return 10 + case .transposedCharacters: + return 24 + case .substitutedCharacter: + return 40 + } + } + } + + private struct SingleEditWordPrefixMatch { + let matchedIndices: Set<Int> + let segmentStart: Int + let segmentLength: Int + let prefixLength: Int + let editPosition: Int + let editKind: SingleEditWordPrefixEditKind + } + + struct PreparedQuery { + let normalizedText: String + let tokens: [String] + + var isEmpty: Bool { + tokens.isEmpty + } + } + + static func preparedQuery(_ query: String) -> PreparedQuery { + let normalizedQuery = normalizeForSearch(query) + return PreparedQuery( + normalizedText: normalizedQuery, + tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + ) + } + + static func normalizeForSearch(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + static func score(query: String, candidates: [String]) -> Int? { + score( + preparedQuery: preparedQuery(query), + normalizedCandidates: candidates + .map(normalizeForSearch) + .filter { !$0.isEmpty } + ) + } + + static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } + guard !normalizedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in preparedQuery.tokens { + var bestTokenScore: Int? + for candidate in normalizedCandidates { + guard let candidateScore = scoreToken(token, in: candidate) else { continue } + bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) + } + guard let bestTokenScore else { return nil } + totalScore += bestTokenScore + } + return totalScore + } + + static func matchCharacterIndices(query: String, candidate: String) -> Set<Int> { + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } + + static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set<Int> { + guard !preparedQuery.isEmpty else { return [] } + + let loweredCandidate = normalizeForSearch(candidate) + guard !loweredCandidate.isEmpty else { return [] } + + let candidateChars = Array(loweredCandidate) + var matched: Set<Int> = [] + + for token in preparedQuery.tokens { + if token == loweredCandidate { + matched.formUnion(0..<candidateChars.count) + continue + } + + if loweredCandidate.hasPrefix(token) { + matched.formUnion(0..<min(token.count, candidateChars.count)) + continue + } + + if let range = loweredCandidate.range(of: token) { + let start = loweredCandidate.distance(from: loweredCandidate.startIndex, to: range.lowerBound) + let end = min(candidateChars.count, start + token.count) + matched.formUnion(start..<end) + continue + } + + if let singleEditPrefix = singleEditWordPrefixMatch(token: token, candidate: loweredCandidate) { + matched.formUnion(singleEditPrefix.matchedIndices) + continue + } + + if let initialism = initialismMatchIndices(token: token, candidate: loweredCandidate) { + matched.formUnion(initialism) + continue + } + + if let stitched = stitchedWordPrefixMatchIndices(token: token, candidate: loweredCandidate) { + matched.formUnion(stitched) + continue + } + + guard token.count <= 3 else { continue } + if let subsequence = subsequenceMatchIndices(token: token, candidate: loweredCandidate) { + matched.formUnion(subsequence) + } + } + + return matched + } + + private static func scoreToken(_ token: String, in candidate: String) -> Int? { + guard !token.isEmpty else { return 0 } + + let candidateChars = Array(candidate) + let tokenChars = Array(token) + guard tokenChars.count <= candidateChars.count else { return nil } + + if token == candidate { + return 8000 + } + if candidate.hasPrefix(token) { + return 6800 - max(0, candidate.count - token.count) + } + + var bestScore: Int? + if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { + bestScore = max(bestScore ?? wordExactScore, wordExactScore) + } + if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { + bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) + } + if let singleEditPrefixScore = singleEditWordPrefixScore( + tokenChars: tokenChars, + candidateChars: candidateChars + ) { + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) + } + + if let range = candidate.range(of: token) { + let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidate.count - token.count) + let boundaryBoost: Int = { + guard distance > 0 else { return 220 } + let prior = candidateChars[distance - 1] + return tokenBoundaryChars.contains(prior) ? 180 : 0 + }() + let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty + bestScore = max(bestScore ?? containsScore, containsScore) + } + + if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } + + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) + } + + if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { + bestScore = max(bestScore ?? subsequence, subsequence) + } + + guard let bestScore else { return nil } + return max(1, bestScore) + } + + private static func bestWordScore( + tokenChars: [Character], + candidateChars: [Character], + requireExactWord: Bool + ) -> Int? { + guard !tokenChars.isEmpty else { return nil } + + var best: Int? + for segment in wordSegments(candidateChars) { + let wordLength = segment.end - segment.start + guard tokenChars.count <= wordLength else { continue } + + var matchesPrefix = true + for offset in 0..<tokenChars.count where candidateChars[segment.start + offset] != tokenChars[offset] { + matchesPrefix = false + break + } + guard matchesPrefix else { continue } + if requireExactWord && tokenChars.count != wordLength { continue } + + let lengthPenalty = max(0, wordLength - tokenChars.count) * 6 + let distancePenalty = segment.start * 8 + let trailingPenalty = max(0, candidateChars.count - wordLength) + let scoreBase = requireExactWord ? 6200 : 5600 + let score = scoreBase - distancePenalty - lengthPenalty - trailingPenalty + best = max(best ?? score, score) + } + + return best + } + + private static func singleEditWordPrefixScore( + tokenChars: [Character], + candidateChars: [Character] + ) -> Int? { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars + ) else { + return nil + } + return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + } + + private static func singleEditWordPrefixScore( + match: SingleEditWordPrefixMatch, + candidateLength: Int + ) -> Int { + let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateLength - match.segmentLength) + let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 + return 5000 + - match.editKind.basePenalty + - distancePenalty + - lengthPenalty + - trailingPenalty + - editPositionPenalty + } + + private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { + guard !tokenChars.isEmpty else { return nil } + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matchedStarts: [Int] = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matchedStarts.append(segment.start) + found = true + break + } + } + if !found { return nil } + } + + let firstStart = matchedStarts.first ?? 0 + let skippedWords = max(0, segments.count - tokenChars.count) + return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) + } + + private static func tokenPrefixMatches( + tokenChars: [Character], + tokenStart: Int, + length: Int, + candidateChars: [Character], + candidateStart: Int + ) -> Bool { + guard length >= 0 else { return false } + guard tokenStart + length <= tokenChars.count else { return false } + guard candidateStart + length <= candidateChars.count else { return false } + guard length > 0 else { return true } + + for offset in 0..<length where tokenChars[tokenStart + offset] != candidateChars[candidateStart + offset] { + return false + } + return true + } + + private static func stitchedWordPrefixScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { + guard tokenChars.count >= 4 else { return nil } + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + struct StitchState: Hashable { + let tokenIndex: Int + let wordIndex: Int + let usedWords: Int + } + + var memo: [StitchState: Int?] = [:] + + func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { + if tokenIndex == tokenChars.count { + return usedWords >= 2 ? 0 : nil + } + guard wordIndex < segments.count else { return nil } + + let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) + if let cached = memo[state] { + return cached + } + + var best: Int? + let remainingChars = tokenChars.count - tokenIndex + for segmentIndex in wordIndex..<segments.count { + let segment = segments[segmentIndex] + let segmentLength = segment.end - segment.start + let maxChunk = min(segmentLength, remainingChars) + guard maxChunk > 0 else { continue } + + let skippedWords = max(0, segmentIndex - wordIndex) + let skipPenalty = skippedWords * 120 + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + guard let suffixScore = dfs( + tokenIndex: tokenIndex + chunkLength, + wordIndex: segmentIndex + 1, + usedWords: min(2, usedWords + 1) + ) else { + continue + } + + let chunkCoverage = chunkLength * 220 + let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 + let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 + let distancePenalty = segment.start * 4 + let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty + let totalScore = suffixScore + chunkScore + best = max(best ?? totalScore, totalScore) + } + } + + memo[state] = best + return best + } + + guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + return 3500 + stitchedScore - lengthPenalty + } + + private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set<Int>? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count >= 4 else { return nil } + + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + var tokenIndex = 0 + var nextWordIndex = 0 + var usedWords = 0 + var matchedIndices: Set<Int> = [] + + while tokenIndex < tokenChars.count { + let remainingChars = tokenChars.count - tokenIndex + var foundMatch = false + + for segmentIndex in nextWordIndex..<segments.count { + let segment = segments[segmentIndex] + let segmentLength = segment.end - segment.start + let maxChunk = min(segmentLength, remainingChars) + guard maxChunk > 0 else { continue } + + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + + matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) + tokenIndex += chunkLength + nextWordIndex = segmentIndex + 1 + usedWords += 1 + foundMatch = true + break + } + + if foundMatch { break } + } + + if !foundMatch { return nil } + } + + guard usedWords >= 2 else { return nil } + return matchedIndices + } + + private static func singleEditWordPrefixMatch( + token: String, + candidate: String + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: Array(token), + candidateChars: Array(candidate) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character] + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + var bestMatch: SingleEditWordPrefixMatch? + var bestScore: Int? + + for segment in wordSegments(candidateChars) { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + if let bestScore, score <= bestScore { + continue + } + bestScore = score + bestMatch = match + } + + return bestMatch + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segment: (start: Int, end: Int) + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + let segmentLength = segment.end - segment.start + guard segmentLength + 1 >= tokenChars.count else { return nil } + + let exactPrefixLength = min(tokenChars.count, segmentLength) + var mismatchOffset = 0 + while mismatchOffset < exactPrefixLength, + candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] + { + mismatchOffset += 1 + } + + if mismatchOffset == tokenChars.count { + let prefixLength = tokenChars.count + 1 + guard segmentLength >= prefixLength else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + tokenChars.count, + editKind: .candidateExtraCharacter + ) + } + + if mismatchOffset == segmentLength { + let prefixLength = tokenChars.count - 1 + guard prefixLength > 0 else { return nil } + guard tokenChars.count == segmentLength + 1 else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + prefixLength)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + prefixLength, + editKind: .tokenExtraCharacter + ) + } + + let mismatchCandidateIndex = segment.start + mismatchOffset + + if segmentLength >= tokenChars.count + 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset, + length: tokenChars.count - mismatchOffset, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count + 1, + editPosition: mismatchCandidateIndex, + editKind: .candidateExtraCharacter + ) + } + + if tokenChars.count >= 2, + segmentLength >= tokenChars.count - 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count - 1, + editPosition: mismatchCandidateIndex, + editKind: .tokenExtraCharacter + ) + } + + if segmentLength >= tokenChars.count, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .substitutedCharacter + ) + } + + if segmentLength >= tokenChars.count, + mismatchOffset + 1 < tokenChars.count, + mismatchCandidateIndex + 1 < segment.end, + tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], + tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 2, + length: tokenChars.count - mismatchOffset - 2, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 2 + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .transposedCharacters + ) + } + + return nil + } + + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { + var segments: [(start: Int, end: Int)] = [] + var index = 0 + + while index < candidateChars.count { + while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + guard index < candidateChars.count else { break } + let start = index + while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + segments.append((start: start, end: index)) + } + + return segments + } + + private static func subsequenceScore(token: String, candidate: String) -> Int? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + score += 90 + if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - tokenChars.count) + return max(1, score) + } + + private static func subsequenceMatchIndices(token: String, candidate: String) -> Set<Int>? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var indices: Set<Int> = [] + var searchIndex = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchIndex = foundIndex else { return nil } + indices.insert(matchIndex) + searchIndex = matchIndex + 1 + } + + return indices + } + + private static func initialismMatchIndices(token: String, candidate: String) -> Set<Int>? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard !tokenChars.isEmpty else { return nil } + + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matched: Set<Int> = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matched.insert(segment.start) + found = true + break + } + } + if !found { return nil } + } + + return matched + } +} + +struct CommandPaletteSearchCorpusEntry<Payload>: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let normalizedSearchableTexts: [String] + + init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { + self.payload = payload + self.rank = rank + self.title = title + self.normalizedSearchableTexts = searchableTexts + .map(CommandPaletteFuzzyMatcher.normalizeForSearch) + .filter { !$0.isEmpty } + } +} + +struct CommandPaletteSearchCorpusResult<Payload>: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set<Int> +} + +enum CommandPaletteSearchEngine { + static func search<Payload: Sendable>( + entries: [CommandPaletteSearchCorpusEntry<Payload>], + query: String, + historyBoost: (Payload, Bool) -> Int + ) -> [CommandPaletteSearchCorpusResult<Payload>] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: nil + ) + } + + static func search<Payload: Sendable>( + entries: [CommandPaletteSearchCorpusEntry<Payload>], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: @escaping () -> Bool + ) -> [CommandPaletteSearchCorpusResult<Payload>] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: Optional(shouldCancel) + ) + } + + private static func search<Payload: Sendable>( + entries: [CommandPaletteSearchCorpusEntry<Payload>], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: (() -> Bool)? + ) -> [CommandPaletteSearchCorpusResult<Payload>] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let queryIsEmpty = preparedQuery.isEmpty + var results: [CommandPaletteSearchCorpusResult<Payload>] = [] + results.reserveCapacity(entries.count) + + func shouldCancelSearch(at index: Int) -> Bool { + guard let shouldCancel else { return false } + return index % 16 == 0 && shouldCancel() + } + + if queryIsEmpty { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: historyBoost(entry.payload, true), + titleMatchIndices: [] + ) + ) + } + } else { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: entry.normalizedSearchableTexts + ) else { + continue + } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: fuzzyScore + historyBoost(entry.payload, false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + candidate: entry.title + ) + ) + ) + } + } + + if shouldCancel?() == true { return [] } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } +} + private struct SidebarResizerAccessibilityModifier: ViewModifier { let accessibilityIdentifier: String? @@ -1715,11 +7151,13 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var notificationStore: TerminalNotificationStore @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set<UUID> @Binding var lastSidebarSelectionIndex: Int? - @StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor() + @StateObject private var modifierKeyMonitor = SidebarShortcutHintModifierMonitor() @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @State private var draggedTabId: UUID? @@ -1741,17 +7179,29 @@ struct VerticalTabsSidebar: View { LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in TabItemView( + tabManager: tabManager, + notificationStore: notificationStore, tab: tab, index: index, + isActive: tabManager.selectedTabId == tab.id, + tabCount: tabManager.tabs.count, + unreadCount: notificationStore.unreadCount(forTabId: tab.id), + latestNotificationText: { + guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil } + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }(), rowSpacing: tabRowSpacing, - selection: $selection, + setSelectionToTabs: { selection = .tabs }, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsCommandShortcutHints: commandKeyMonitor.isCommandPressed, + showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, dropIndicator: $dropIndicator ) + .equatable() } } .padding(.vertical, 8) @@ -1780,35 +7230,28 @@ struct VerticalTabsSidebar: View { .allowsHitTesting(false) } .overlay(alignment: .top) { - // Double-click the sidebar title-bar area to zoom the - // window, matching the panel top-bar behaviour. - DoubleClickZoomView() + // Match native titlebar behavior in the sidebar top strip: + // drag-to-move and double-click action (zoom/minimize). + WindowDragHandleView() .frame(height: trafficLightPadding) } .background(Color.clear) .modifier(ClearScrollBackground()) } -#if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel) + SidebarFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) .frame(maxWidth: .infinity, alignment: .leading) -#else - UpdatePill(model: updateViewModel) - .padding(.horizontal, 10) - .padding(.bottom, 10) - .frame(maxWidth: .infinity, alignment: .leading) -#endif } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .background(SidebarBackdrop().ignoresSafeArea()) .background( WindowAccessor { window in - commandKeyMonitor.setHostWindow(window) + modifierKeyMonitor.setHostWindow(window) } .frame(width: 0, height: 0) ) .onAppear { - commandKeyMonitor.start() + modifierKeyMonitor.start() draggedTabId = nil dropIndicator = nil SidebarDragLifecycleNotification.postStateDidChange( @@ -1817,7 +7260,7 @@ struct VerticalTabsSidebar: View { ) } .onDisappear { - commandKeyMonitor.stop() + modifierKeyMonitor.stop() dragAutoScrollController.stop() dragFailsafeMonitor.stop() draggedTabId = nil @@ -1861,11 +7304,18 @@ struct VerticalTabsSidebar: View { } } -enum SidebarCommandHintPolicy { +enum ShortcutHintModifierPolicy { static let intentionalHoldDelay: TimeInterval = 0.30 - static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool { - modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] + static func shouldShowHints( + for modifierFlags: NSEvent.ModifierFlags, + defaults: UserDefaults = .standard + ) -> Bool { + let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) + guard normalized == [.command] else { + return false + } + return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) } static func isCurrentWindow( @@ -1886,9 +7336,10 @@ enum SidebarCommandHintPolicy { hostWindowNumber: Int?, hostWindowIsKey: Bool, eventWindowNumber: Int?, - keyWindowNumber: Int? + keyWindowNumber: Int?, + defaults: UserDefaults = .standard ) -> Bool { - shouldShowHints(for: modifierFlags) && + shouldShowHints(for: modifierFlags, defaults: defaults) && isCurrentWindow( hostWindowNumber: hostWindowNumber, hostWindowIsKey: hostWindowIsKey, @@ -1906,6 +7357,7 @@ enum ShortcutHintDebugSettings { static let paneHintXKey = "shortcutHintPaneTabXOffset" static let paneHintYKey = "shortcutHintPaneTabYOffset" static let alwaysShowHintsKey = "shortcutHintAlwaysShow" + static let showHintsOnCommandHoldKey = "shortcutHintShowOnCommandHold" static let defaultSidebarHintX = 0.0 static let defaultSidebarHintY = 0.0 @@ -1914,12 +7366,362 @@ enum ShortcutHintDebugSettings { static let defaultPaneHintX = 0.0 static let defaultPaneHintY = 0.0 static let defaultAlwaysShowHints = false + static let defaultShowHintsOnCommandHold = true static let offsetRange: ClosedRange<Double> = -20...20 static func clamped(_ value: Double) -> Double { min(max(value, offsetRange.lowerBound), offsetRange.upperBound) } + + static func showHintsOnCommandHoldEnabled(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: showHintsOnCommandHoldKey) != nil else { + return defaultShowHintsOnCommandHold + } + return defaults.bool(forKey: showHintsOnCommandHoldKey) + } + + static func resetVisibilityDefaults(defaults: UserDefaults = .standard) { + defaults.set(defaultAlwaysShowHints, forKey: alwaysShowHintsKey) + defaults.set(defaultShowHintsOnCommandHold, forKey: showHintsOnCommandHoldKey) + } +} + +enum DevBuildBannerDebugSettings { + static let sidebarBannerVisibleKey = "showSidebarDevBuildBanner" + static let defaultShowSidebarBanner = true + + static func showSidebarBanner(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: sidebarBannerVisibleKey) != nil else { + return defaultShowSidebarBanner + } + return defaults.bool(forKey: sidebarBannerVisibleKey) + } +} + +private enum FeedbackComposerSettings { + static let storedEmailKey = "sidebarHelpFeedbackEmail" + static let endpointEnvironmentKey = "CMUX_FEEDBACK_API_URL" + static let defaultEndpoint = "https://www.cmux.dev/api/feedback" + static let foundersEmail = "founders@manaflow.com" + static let maxMessageLength = 4_000 + static let maxAttachmentCount = 10 + // Keep the multipart body below Vercel's 4.5 MB request limit. + static let maxTotalAttachmentBytes = 4 * 1_024 * 1_024 + static let targetTotalAttachmentUploadBytes = 3_500_000 + + static func endpointURL() -> URL? { + let env = ProcessInfo.processInfo.environment + if let override = env[endpointEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return URL(string: override) + } + return URL(string: defaultEndpoint) + } +} + +private struct FeedbackComposerAttachment: Identifiable { + let id = UUID() + let url: URL + let fileName: String + let fileSize: Int64 + let mimeType: String + + var standardizedPath: String { + url.standardizedFileURL.path + } + + var displaySize: String { + ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file) + } + + init(url: URL) throws { + let resourceValues = try url.resourceValues(forKeys: [ + .contentTypeKey, + .fileSizeKey, + .isRegularFileKey, + .nameKey, + ]) + guard resourceValues.isRegularFile != false else { + throw CocoaError(.fileReadUnknown) + } + + self.url = url + self.fileName = resourceValues.name ?? url.lastPathComponent + self.fileSize = Int64(resourceValues.fileSize ?? 0) + self.mimeType = resourceValues.contentType?.preferredMIMEType ?? "application/octet-stream" + } +} + +private struct PreparedFeedbackComposerAttachment { + let fileName: String + let mimeType: String + let data: Data +} + +private struct FeedbackComposerAppMetadata { + let appVersion: String + let appBuild: String + let appCommit: String + let bundleIdentifier: String + let osVersion: String + let localeIdentifier: String + + static var current: FeedbackComposerAppMetadata { + let infoDictionary = Bundle.main.infoDictionary ?? [:] + let env = ProcessInfo.processInfo.environment + let commit = (infoDictionary["CMUXCommit"] as? String).flatMap { value in + value.isEmpty ? nil : value + } ?? env["CMUX_COMMIT"] + + return FeedbackComposerAppMetadata( + appVersion: infoDictionary["CFBundleShortVersionString"] as? String ?? "", + appBuild: infoDictionary["CFBundleVersion"] as? String ?? "", + appCommit: commit ?? "", + bundleIdentifier: Bundle.main.bundleIdentifier ?? "", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier + ) + } +} + +private enum FeedbackComposerSubmissionError: Error { + case invalidEndpoint + case invalidResponse + case rejected(statusCode: Int) + case attachmentReadFailed + case attachmentPreparationFailed + case transport(URLError) +} + +private enum FeedbackComposerClient { + private static let passthroughAttachmentMIMETypes: Set<String> = [ + "image/gif", + "image/heic", + "image/heif", + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", + ] + private static let optimizedAttachmentDimensions: [Int] = [2800, 2400, 2000, 1600, 1280, 1024, 768, 640, 512] + private static let optimizedAttachmentQualities: [CGFloat] = [0.82, 0.72, 0.62, 0.52, 0.42, 0.32] + private static let optimizedAttachmentMIMEType = "image/jpeg" + + static func submit( + email: String, + message: String, + attachments: [FeedbackComposerAttachment] + ) async throws { + guard let endpointURL = FeedbackComposerSettings.endpointURL() else { + throw FeedbackComposerSubmissionError.invalidEndpoint + } + + let metadata = FeedbackComposerAppMetadata.current + let boundary = "Boundary-\(UUID().uuidString)" + let preparedAttachments = try prepareAttachmentsForUpload(attachments) + + var request = URLRequest(url: endpointURL) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + var body = Data() + appendField("email", value: email, to: &body, boundary: boundary) + appendField("message", value: message, to: &body, boundary: boundary) + appendField("appVersion", value: metadata.appVersion, to: &body, boundary: boundary) + appendField("appBuild", value: metadata.appBuild, to: &body, boundary: boundary) + appendField("appCommit", value: metadata.appCommit, to: &body, boundary: boundary) + appendField("bundleIdentifier", value: metadata.bundleIdentifier, to: &body, boundary: boundary) + appendField("osVersion", value: metadata.osVersion, to: &body, boundary: boundary) + appendField("locale", value: metadata.localeIdentifier, to: &body, boundary: boundary) + + for attachment in preparedAttachments { + appendFile( + named: "attachments", + attachment: attachment, + to: &body, + boundary: boundary + ) + } + + body.append(Data("--\(boundary)--\r\n".utf8)) + request.httpBody = body + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch let error as URLError { + throw FeedbackComposerSubmissionError.transport(error) + } catch { + throw FeedbackComposerSubmissionError.invalidResponse + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw FeedbackComposerSubmissionError.invalidResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + if let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorMessage = payload["error"] as? String, + errorMessage.isEmpty == false { + NSLog("feedback.submit.rejected status=%@ error=%@", String(httpResponse.statusCode), errorMessage) + } + throw FeedbackComposerSubmissionError.rejected(statusCode: httpResponse.statusCode) + } + } + + private static func appendField( + _ name: String, + value: String, + to body: inout Data, + boundary: String + ) { + body.append(Data("--\(boundary)\r\n".utf8)) + body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) + body.append(Data(value.utf8)) + body.append(Data("\r\n".utf8)) + } + + private static func prepareAttachmentsForUpload( + _ attachments: [FeedbackComposerAttachment] + ) throws -> [PreparedFeedbackComposerAttachment] { + guard attachments.isEmpty == false else { return [] } + + struct IndexedAttachment { + let index: Int + let attachment: FeedbackComposerAttachment + } + + let sortedAttachments = attachments.enumerated() + .map { IndexedAttachment(index: $0.offset, attachment: $0.element) } + .sorted { lhs, rhs in + lhs.attachment.fileSize > rhs.attachment.fileSize + } + + var preparedByIndex: [Int: PreparedFeedbackComposerAttachment] = [:] + var remainingBudget = FeedbackComposerSettings.targetTotalAttachmentUploadBytes + var remainingCount = sortedAttachments.count + + for item in sortedAttachments { + let perAttachmentBudget = max(1, remainingBudget / max(remainingCount, 1)) + let preparedAttachment = try prepareAttachmentForUpload( + item.attachment, + maximumByteCount: perAttachmentBudget + ) + preparedByIndex[item.index] = preparedAttachment + remainingBudget -= preparedAttachment.data.count + remainingCount -= 1 + } + + let preparedAttachments = attachments.indices.compactMap { preparedByIndex[$0] } + let totalBytes = preparedAttachments.reduce(0) { $0 + $1.data.count } + guard totalBytes <= FeedbackComposerSettings.targetTotalAttachmentUploadBytes else { + throw FeedbackComposerSubmissionError.attachmentPreparationFailed + } + return preparedAttachments + } + + private static func prepareAttachmentForUpload( + _ attachment: FeedbackComposerAttachment, + maximumByteCount: Int + ) throws -> PreparedFeedbackComposerAttachment { + if attachment.fileSize > 0, + attachment.fileSize <= Int64(maximumByteCount), + passthroughAttachmentMIMETypes.contains(attachment.mimeType), + let fileData = try? Data(contentsOf: attachment.url, options: .mappedIfSafe) { + return PreparedFeedbackComposerAttachment( + fileName: attachment.fileName, + mimeType: attachment.mimeType, + data: fileData + ) + } + + guard let imageSource = CGImageSourceCreateWithURL(attachment.url as CFURL, nil) else { + throw FeedbackComposerSubmissionError.attachmentReadFailed + } + + for maxPixelDimension in optimizedAttachmentDimensions { + guard let cgImage = downsampledImage( + from: imageSource, + maxPixelDimension: maxPixelDimension + ) else { continue } + + for compressionQuality in optimizedAttachmentQualities { + guard let jpegData = jpegData( + from: cgImage, + compressionQuality: compressionQuality + ) else { continue } + guard jpegData.count <= maximumByteCount else { continue } + + return PreparedFeedbackComposerAttachment( + fileName: optimizedFileName(for: attachment), + mimeType: optimizedAttachmentMIMEType, + data: jpegData + ) + } + } + + throw FeedbackComposerSubmissionError.attachmentPreparationFailed + } + + private static func downsampledImage( + from imageSource: CGImageSource, + maxPixelDimension: Int + ) -> CGImage? { + CGImageSourceCreateThumbnailAtIndex( + imageSource, + 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceThumbnailMaxPixelSize: maxPixelDimension, + ] as CFDictionary + ) + } + + private static func jpegData( + from image: CGImage, + compressionQuality: CGFloat + ) -> Data? { + let bitmap = NSBitmapImageRep(cgImage: image) + return bitmap.representation( + using: .jpeg, + properties: [ + .compressionFactor: compressionQuality, + ] + ) + } + + private static func optimizedFileName( + for attachment: FeedbackComposerAttachment + ) -> String { + let baseName = (attachment.fileName as NSString).deletingPathExtension + return "\(baseName.isEmpty ? "feedback-image" : baseName).jpg" + } + + private static func appendFile( + named fieldName: String, + attachment: PreparedFeedbackComposerAttachment, + to body: inout Data, + boundary: String + ) { + let sanitizedFileName = attachment.fileName.replacingOccurrences(of: "\"", with: "") + + body.append(Data("--\(boundary)\r\n".utf8)) + body.append( + Data( + "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(sanitizedFileName)\"\r\n".utf8 + ) + ) + body.append(Data("Content-Type: \(attachment.mimeType)\r\n\r\n".utf8)) + body.append(attachment.data) + body.append(Data("\r\n".utf8)) + } } enum SidebarDragLifecycleNotification { @@ -2070,7 +7872,7 @@ private struct SidebarExternalDropOverlay: View { .contentShape(Rectangle()) .allowsHitTesting(true) .onDrop( - of: [SidebarTabDragPayload.typeIdentifier], + of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) ) } else { @@ -2137,8 +7939,8 @@ private struct SidebarExternalDropDelegate: DropDelegate { } @MainActor -private final class SidebarCommandKeyMonitor: ObservableObject { - @Published private(set) var isCommandPressed = false +private final class SidebarShortcutHintModifierMonitor: ObservableObject { + @Published private(set) var isModifierPressed = false private weak var hostWindow: NSWindow? private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? @@ -2232,7 +8034,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, eventWindowNumber: eventWindow?.windowNumber, @@ -2241,7 +8043,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: modifierFlags, hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, @@ -2256,31 +8058,31 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } private func queueHintShow() { - guard !isCommandPressed else { return } + guard !isModifierPressed else { return } guard pendingShowWorkItem == nil else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: NSEvent.modifierFlags, hostWindowNumber: self.hostWindow?.windowNumber, hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, eventWindowNumber: nil, keyWindowNumber: NSApp.keyWindow?.windowNumber ) else { return } - self.isCommandPressed = true + self.isModifierPressed = true } pendingShowWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem) } private func cancelPendingHintShow(resetVisible: Bool) { pendingShowWorkItem?.cancel() pendingShowWorkItem = nil if resetVisible { - isCommandPressed = false + isModifierPressed = false } } @@ -2296,19 +8098,1146 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } } +private struct SidebarFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { +#if DEBUG + SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) +#else + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) +#endif + } +} + +private struct SidebarFooterButtons: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { + HStack(spacing: 4) { + SidebarHelpMenuButton(onSendFeedback: onSendFeedback) + UpdatePill(model: updateViewModel) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct FeedbackComposerMessageEditor: NSViewRepresentable { + @Binding var text: String + let placeholder: String + let accessibilityLabel: String + let accessibilityIdentifier: String + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> FeedbackComposerMessageEditorView { + let view = FeedbackComposerMessageEditorView() + view.placeholder = placeholder + view.textView.string = text + view.textView.delegate = context.coordinator + view.textView.setAccessibilityLabel(accessibilityLabel) + view.textView.setAccessibilityIdentifier(accessibilityIdentifier) + view.setAccessibilityIdentifier(accessibilityIdentifier) + return view + } + + func updateNSView(_ nsView: FeedbackComposerMessageEditorView, context: Context) { + if nsView.textView.string != text { + nsView.textView.string = text + } + nsView.placeholder = placeholder + nsView.textView.setAccessibilityLabel(accessibilityLabel) + nsView.textView.setAccessibilityIdentifier(accessibilityIdentifier) + nsView.setAccessibilityIdentifier(accessibilityIdentifier) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: FeedbackComposerMessageEditor + + init(parent: FeedbackComposerMessageEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + } + } +} + +private final class FeedbackComposerPassthroughLabel: NSTextField { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +private final class FeedbackComposerMessageScrollView: NSScrollView { + weak var focusTextView: NSTextView? + + override func mouseDown(with event: NSEvent) { + if let focusTextView { + _ = window?.makeFirstResponder(focusTextView) + } + super.mouseDown(with: event) + } +} + +private final class FeedbackComposerMessageEditorView: NSView { + private static let textInset = NSSize(width: 10, height: 10) + + let scrollView = FeedbackComposerMessageScrollView() + let textView = NSTextView() + private let placeholderField = FeedbackComposerPassthroughLabel(labelWithString: "") + + var placeholder: String = "" { + didSet { + placeholderField.stringValue = placeholder + updatePlaceholderVisibility() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + wantsLayer = true + layer?.cornerRadius = 8 + layer?.borderWidth = 1 + layer?.borderColor = NSColor.separatorColor.cgColor + layer?.backgroundColor = NSColor.textBackgroundColor.cgColor + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.automaticallyAdjustsContentInsets = false + scrollView.hasVerticalScroller = true + scrollView.focusTextView = textView + + textView.translatesAutoresizingMaskIntoConstraints = false + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.font = .systemFont(ofSize: 12) + textView.textColor = .labelColor + textView.insertionPointColor = .labelColor + textView.textContainerInset = Self.textInset + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + textView.minSize = .zero + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + + scrollView.documentView = textView + addSubview(scrollView) + + placeholderField.translatesAutoresizingMaskIntoConstraints = false + placeholderField.font = .systemFont(ofSize: 12) + placeholderField.textColor = .secondaryLabelColor + placeholderField.lineBreakMode = .byWordWrapping + placeholderField.maximumNumberOfLines = 0 + scrollView.contentView.addSubview(placeholderField) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange(_:)), + name: NSText.didChangeNotification, + object: textView + ) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + + placeholderField.topAnchor.constraint( + equalTo: scrollView.contentView.topAnchor, + constant: Self.textInset.height + ), + placeholderField.leadingAnchor.constraint( + equalTo: scrollView.contentView.leadingAnchor, + constant: Self.textInset.width + ), + placeholderField.trailingAnchor.constraint( + lessThanOrEqualTo: scrollView.contentView.trailingAnchor, + constant: -Self.textInset.width + ), + ]) + + updatePlaceholderVisibility() + } + + override func layout() { + super.layout() + syncTextViewFrameToContentSize() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc + private func textDidChange(_ notification: Notification) { + updatePlaceholderVisibility() + } + + private func updatePlaceholderVisibility() { + placeholderField.isHidden = textView.string.isEmpty == false + } + + private func syncTextViewFrameToContentSize() { + let contentSize = scrollView.contentSize + guard contentSize.width > 0, contentSize.height > 0 else { return } + + textView.minSize = NSSize(width: 0, height: contentSize.height) + textView.textContainer?.containerSize = NSSize( + width: contentSize.width, + height: CGFloat.greatestFiniteMagnitude + ) + + let targetSize = NSSize( + width: contentSize.width, + height: max(textView.frame.height, contentSize.height) + ) + if textView.frame.size != targetSize { + textView.frame = NSRect(origin: .zero, size: targetSize) + } + } +} + +private enum SidebarHelpMenuAction { + case keyboardShortcuts + case docs + case changelog + case github + case githubIssues + case checkForUpdates + case sendFeedback + case welcome +} + +private struct SidebarFeedbackComposerSheet: View { + @AppStorage(FeedbackComposerSettings.storedEmailKey) private var email = "" + @Environment(\.dismiss) private var dismiss + + @State private var message = "" + @State private var attachments: [FeedbackComposerAttachment] = [] + @State private var isSubmitting = false + @State private var submissionErrorMessage: String? + @State private var didSend = false + + private var trimmedMessage: String { + message.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmit: Bool { + isValidEmail(email) && + !trimmedMessage.isEmpty && + message.count <= FeedbackComposerSettings.maxMessageLength && + !isSubmitting && + !didSend + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(localized: "sidebar.help.feedback.title", defaultValue: "Send Feedback")) + .font(.title3.weight(.semibold)) + + if didSend { + successView + } else { + formView + } + } + .padding(20) + .frame(width: 520) + .accessibilityIdentifier("SidebarFeedbackDialog") + } + + private var successView: some View { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "sidebar.help.feedback.successTitle", defaultValue: "Thanks for the feedback.")) + .font(.headline) + Text( + String( + localized: "sidebar.help.feedback.successBody", + defaultValue: "You can also reach us at founders@manaflow.com." + ) + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + HStack { + Spacer() + Button(String(localized: "sidebar.help.feedback.done", defaultValue: "Done")) { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + } + + private var formView: some View { + VStack(alignment: .leading, spacing: 14) { + Text( + String( + localized: "sidebar.help.feedback.note", + defaultValue: "A human will read this! You can also reach us at founders@manaflow.com." + ) + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email")) + .font(.system(size: 12, weight: .medium)) + TextField( + String(localized: "sidebar.help.feedback.emailPlaceholder", defaultValue: "you@example.com"), + text: $email + ) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email")) + .accessibilityIdentifier("SidebarFeedbackEmailField") + } + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(String(localized: "sidebar.help.feedback.message", defaultValue: "Message")) + .font(.system(size: 12, weight: .medium)) + Spacer(minLength: 0) + Text("\(message.count)/\(FeedbackComposerSettings.maxMessageLength)") + .font(.system(size: 11)) + .foregroundStyle( + message.count > FeedbackComposerSettings.maxMessageLength + ? Color.red + : Color.secondary + ) + } + + FeedbackComposerMessageEditor( + text: $message, + placeholder: String( + localized: "sidebar.help.feedback.messagePlaceholder", + defaultValue: "Share feedback, feature requests, or issues." + ), + accessibilityLabel: String(localized: "sidebar.help.feedback.message", defaultValue: "Message"), + accessibilityIdentifier: "SidebarFeedbackMessageEditor" + ) + .frame(minHeight: 180) + } + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button { + chooseAttachments() + } label: { + Label( + String(localized: "sidebar.help.feedback.attachImages", defaultValue: "Attach Images"), + systemImage: "paperclip" + ) + } + .accessibilityIdentifier("SidebarFeedbackAttachButton") + + Text( + String( + localized: "sidebar.help.feedback.attachmentsHint", + defaultValue: "Up to 10 images." + ) + ) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + if attachments.isEmpty == false { + VStack(alignment: .leading, spacing: 6) { + ForEach(attachments) { attachment in + HStack(spacing: 8) { + Image(systemName: "photo") + .foregroundStyle(.secondary) + Text(attachment.fileName) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(attachment.displaySize) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Button( + String(localized: "sidebar.help.feedback.removeAttachment", defaultValue: "Remove") + ) { + removeAttachment(attachment) + } + .buttonStyle(.link) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + } + } + + if let submissionErrorMessage, submissionErrorMessage.isEmpty == false { + Text(submissionErrorMessage) + .font(.system(size: 12)) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button(String(localized: "sidebar.help.feedback.cancel", defaultValue: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button { + Task { await submitFeedback() } + } label: { + if isSubmitting { + ProgressView() + .controlSize(.small) + } else { + Text(String(localized: "sidebar.help.feedback.send", defaultValue: "Send")) + } + } + .keyboardShortcut(.defaultAction) + .disabled(!canSubmit) + .accessibilityIdentifier("SidebarFeedbackSendButton") + } + } + } + + private func chooseAttachments() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.allowedContentTypes = [.image] + panel.title = String( + localized: "sidebar.help.feedback.attachImages.title", + defaultValue: "Attach Images" + ) + panel.prompt = String( + localized: "sidebar.help.feedback.attachImages.prompt", + defaultValue: "Attach" + ) + + guard panel.runModal() == .OK else { return } + + var updatedAttachments = attachments + var knownPaths = Set(updatedAttachments.map(\.standardizedPath)) + var firstIssue: String? + + for url in panel.urls { + let normalizedPath = url.standardizedFileURL.path + if knownPaths.contains(normalizedPath) { + continue + } + if updatedAttachments.count >= FeedbackComposerSettings.maxAttachmentCount { + firstIssue = String( + localized: "sidebar.help.feedback.tooManyImages", + defaultValue: "You can attach up to 10 images." + ) + break + } + + guard let attachment = try? FeedbackComposerAttachment(url: url) else { + firstIssue = String( + localized: "sidebar.help.feedback.invalidImageSelection", + defaultValue: "One of the selected files could not be attached." + ) + continue + } + updatedAttachments.append(attachment) + knownPaths.insert(normalizedPath) + } + + attachments = updatedAttachments + submissionErrorMessage = firstIssue + } + + private func removeAttachment(_ attachment: FeedbackComposerAttachment) { + attachments.removeAll { $0.id == attachment.id } + submissionErrorMessage = nil + } + + private func submitFeedback() async { + let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMessage = trimmedMessage + + guard isValidEmail(trimmedEmail) else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.invalidEmail", + defaultValue: "Enter a valid email address." + ) + return + } + + guard normalizedMessage.isEmpty == false else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.emptyMessage", + defaultValue: "Enter a message before sending." + ) + return + } + + guard message.count <= FeedbackComposerSettings.maxMessageLength else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.messageTooLong", + defaultValue: "Your message is too long." + ) + return + } + + await MainActor.run { + email = trimmedEmail + submissionErrorMessage = nil + isSubmitting = true + } + + do { + try await FeedbackComposerClient.submit( + email: trimmedEmail, + message: normalizedMessage, + attachments: attachments + ) + await MainActor.run { + isSubmitting = false + didSend = true + attachments = [] + } + } catch { + await MainActor.run { + isSubmitting = false + submissionErrorMessage = userFacingErrorMessage(for: error) + } + } + } + + private func isValidEmail(_ rawValue: String) -> Bool { + let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard email.isEmpty == false else { return false } + let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"# + return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email) + } + + private func userFacingErrorMessage(for error: Error) -> String { + guard let submissionError = error as? FeedbackComposerSubmissionError else { + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + } + + switch submissionError { + case .invalidEndpoint: + return String( + localized: "sidebar.help.feedback.endpointError", + defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead." + ) + case .invalidResponse: + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + case .attachmentReadFailed: + return String( + localized: "sidebar.help.feedback.invalidImageSelection", + defaultValue: "One of the selected files could not be attached." + ) + case .attachmentPreparationFailed: + return String( + localized: "sidebar.help.feedback.totalImagesTooLarge", + defaultValue: "These images are too large to send together. Remove a few and try again." + ) + case .transport(let transportError): + if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost { + return String( + localized: "sidebar.help.feedback.connectionError", + defaultValue: "Couldn't send feedback. Check your connection and try again." + ) + } + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + case .rejected(let statusCode): + switch statusCode { + case 400, 413, 415: + return String( + localized: "sidebar.help.feedback.validationError", + defaultValue: "Check your message and attachments, then try again." + ) + case 429: + return String( + localized: "sidebar.help.feedback.rateLimited", + defaultValue: "Too many feedback attempts. Please try again later." + ) + case 500...599: + return String( + localized: "sidebar.help.feedback.endpointError", + defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead." + ) + default: + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + } + } + } +} + +enum FeedbackComposerBridgeError: LocalizedError { + case invalidEmail + case emptyMessage + case messageTooLong + case tooManyImages + case invalidImagePath(String) + case submissionFailed(String) + + var errorDescription: String? { + switch self { + case .invalidEmail: + return "Enter a valid email address." + case .emptyMessage: + return "Enter a message before sending." + case .messageTooLong: + return "Your message is too long." + case .tooManyImages: + return "You can attach up to 10 images." + case .invalidImagePath(let path): + return "Could not attach image: \(path)" + case .submissionFailed(let message): + return message + } + } +} + +enum FeedbackComposerBridge { + static func openComposer(in window: NSWindow? = NSApp.keyWindow ?? NSApp.mainWindow) { + NotificationCenter.default.post(name: .feedbackComposerRequested, object: window) + } + + static func submit( + email: String, + message: String, + imagePaths: [String] + ) async throws -> Int { + let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) + + guard isValidEmail(trimmedEmail) else { + throw FeedbackComposerBridgeError.invalidEmail + } + guard normalizedMessage.isEmpty == false else { + throw FeedbackComposerBridgeError.emptyMessage + } + guard message.count <= FeedbackComposerSettings.maxMessageLength else { + throw FeedbackComposerBridgeError.messageTooLong + } + guard imagePaths.count <= FeedbackComposerSettings.maxAttachmentCount else { + throw FeedbackComposerBridgeError.tooManyImages + } + + let attachments = try imagePaths.map { rawPath in + let resolvedURL = URL(fileURLWithPath: rawPath).standardizedFileURL + do { + return try FeedbackComposerAttachment(url: resolvedURL) + } catch { + throw FeedbackComposerBridgeError.invalidImagePath(resolvedURL.path) + } + } + + do { + try await FeedbackComposerClient.submit( + email: trimmedEmail, + message: normalizedMessage, + attachments: attachments + ) + } catch { + throw FeedbackComposerBridgeError.submissionFailed(userFacingMessage(for: error)) + } + + UserDefaults.standard.set(trimmedEmail, forKey: FeedbackComposerSettings.storedEmailKey) + return attachments.count + } + + private static func isValidEmail(_ rawValue: String) -> Bool { + let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard email.isEmpty == false else { return false } + let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"# + return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email) + } + + private static func userFacingMessage(for error: Error) -> String { + guard let submissionError = error as? FeedbackComposerSubmissionError else { + return "Couldn't send feedback. Please try again." + } + + switch submissionError { + case .invalidEndpoint: + return "Feedback is unavailable right now. Email founders@manaflow.com instead." + case .invalidResponse: + return "Couldn't send feedback. Please try again." + case .attachmentReadFailed: + return "One of the selected files could not be attached." + case .attachmentPreparationFailed: + return "These images are too large to send together. Remove a few and try again." + case .transport(let transportError): + if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost { + return "Couldn't send feedback. Check your connection and try again." + } + return "Couldn't send feedback. Please try again." + case .rejected(let statusCode): + switch statusCode { + case 400, 413, 415: + return "Check your message and attachments, then try again." + case 429: + return "Too many feedback attempts. Please try again later." + case 500...599: + return "Feedback is unavailable right now. Email founders@manaflow.com instead." + default: + return "Couldn't send feedback. Please try again." + } + } + } +} + +private struct SidebarHelpMenuButton: View { + private let docsURL = URL(string: "https://cmux.dev/docs") + private let changelogURL = URL(string: "https://cmux.dev/docs/changelog") + private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux") + private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues") + private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") + private let buttonSize: CGFloat = 22 + private let iconSize: CGFloat = 11 + @AppStorage(KeyboardShortcutSettings.Action.sendFeedback.defaultsKey) private var sendFeedbackShortcutData = Data() + + let onSendFeedback: () -> Void + + @State private var isPopoverPresented = false + + private var sendFeedbackShortcutHint: String { + decodeShortcut( + from: sendFeedbackShortcutData, + fallback: KeyboardShortcutSettings.Action.sendFeedback.defaultShortcut + ).displayString + } + + var body: some View { + Button { + isPopoverPresented.toggle() + } label: { + Image(systemName: "questionmark.circle") + .symbolRenderingMode(.monochrome) + .font(.system(size: iconSize, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + .background(ArrowlessPopoverAnchor( + isPresented: $isPopoverPresented, + preferredEdge: .maxY, + detachedGap: 4 + ) { + helpPopover + }) + .accessibilityElement(children: .ignore) + .safeHelp(helpTitle) + .accessibilityLabel(helpTitle) + .accessibilityIdentifier("SidebarHelpMenuButton") + } + + private var helpPopover: some View { + VStack(alignment: .leading, spacing: 2) { + helpOptionButton( + title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"), + action: .welcome, + accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", + isExternalLink: false + ) + helpOptionButton( + title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), + action: .sendFeedback, + accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", + isExternalLink: false, + shortcutHint: sendFeedbackShortcutHint, + trailingSystemImage: "bubble.left.and.text.bubble.right" + ) + helpOptionButton( + title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), + action: .keyboardShortcuts, + accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", + isExternalLink: false + ) + if docsURL != nil { + helpOptionButton( + title: String(localized: "about.docs", defaultValue: "Docs"), + action: .docs, + accessibilityIdentifier: "SidebarHelpMenuOptionDocs", + isExternalLink: true + ) + } + if changelogURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), + action: .changelog, + accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", + isExternalLink: true + ) + } + if githubURL != nil { + helpOptionButton( + title: String(localized: "about.github", defaultValue: "GitHub"), + action: .github, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", + isExternalLink: true + ) + } + if githubIssuesURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), + action: .githubIssues, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", + isExternalLink: true + ) + } + helpOptionButton( + title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), + action: .checkForUpdates, + accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", + isExternalLink: false + ) + } + .padding(8) + .frame(minWidth: 200) + } + + private func helpOptionButton( + title: String, + action: SidebarHelpMenuAction, + accessibilityIdentifier: String, + isExternalLink: Bool, + shortcutHint: String? = nil, + trailingSystemImage: String? = nil + ) -> some View { + Button { + isPopoverPresented = false + perform(action) + } label: { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 12)) + Spacer(minLength: 0) + if let shortcutHint { + helpOptionShortcutHint(text: shortcutHint) + } + if let trailingSystemImage { + helpOptionTrailingIcon(systemName: trailingSystemImage) + } + if isExternalLink { + helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) + } + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier) + } + + private func helpOptionShortcutHint(text: String) -> some View { + Text(text) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { + Image(systemName: systemName) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func perform(_ action: SidebarHelpMenuAction) { + switch action { + case .keyboardShortcuts: + isPopoverPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow( + debugSource: "sidebarHelpMenu.keyboardShortcuts", + navigationTarget: .keyboardShortcuts + ) + } else { + AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) + } + } + } + case .docs: + guard let docsURL else { return } + NSWorkspace.shared.open(docsURL) + case .changelog: + guard let changelogURL else { return } + NSWorkspace.shared.open(changelogURL) + case .github: + guard let githubURL else { return } + NSWorkspace.shared.open(githubURL) + case .githubIssues: + guard let githubIssuesURL else { return } + NSWorkspace.shared.open(githubIssuesURL) + case .checkForUpdates: + Task { @MainActor in + AppDelegate.shared?.checkForUpdates(nil) + } + case .sendFeedback: + isPopoverPresented = false + onSendFeedback() + case .welcome: + isPopoverPresented = false + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openWelcomeWorkspace() + } + } + } + } + + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { + guard !data.isEmpty, + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return fallback + } + return shortcut + } +} + +private struct ArrowlessPopoverAnchor<PopoverContent: View>: NSViewRepresentable { + @Binding var isPresented: Bool + let preferredEdge: NSRectEdge + let detachedGap: CGFloat + @ViewBuilder let content: () -> PopoverContent + + func makeNSView(context: Context) -> NSView { + let view = NSView() + context.coordinator.anchorView = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.anchorView = nsView + context.coordinator.updateRootView(AnyView(content())) + + if isPresented { + context.coordinator.present( + preferredEdge: preferredEdge, + detachedGap: detachedGap + ) + } else { + context.coordinator.dismiss() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + final class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + + weak var anchorView: NSView? + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private var popover: NSPopover? + + init(isPresented: Binding<Bool>) { + _isPresented = isPresented + } + + func updateRootView(_ rootView: AnyView) { + hostingController.rootView = AnyView(rootView.fixedSize()) + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + } + + func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { + guard let anchorView else { + isPresented = false + dismiss() + return + } + + let popover = popover ?? makePopover() + if popover.isShown { + return + } + + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + let fittingSize = hostingController.view.fittingSize + if fittingSize.width > 0, fittingSize.height > 0 { + popover.contentSize = NSSize( + width: ceil(fittingSize.width), + height: ceil(fittingSize.height) + ) + } + + popover.show( + relativeTo: positioningRect( + for: anchorView.bounds, + preferredEdge: preferredEdge, + detachedGap: detachedGap + ), + of: anchorView, + preferredEdge: preferredEdge + ) + } + + func dismiss() { + popover?.performClose(nil) + popover = nil + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + if isPresented { + isPresented = false + } + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .semitransient + popover.animates = true + popover.setValue(true, forKeyPath: "shouldHideAnchor") + popover.contentViewController = hostingController + popover.delegate = self + self.popover = popover + return popover + } + + private func positioningRect( + for bounds: CGRect, + preferredEdge: NSRectEdge, + detachedGap: CGFloat + ) -> CGRect { + let hiddenArrowInset: CGFloat = 13 + let compensation = max(hiddenArrowInset - detachedGap, 0) + + switch preferredEdge { + case .maxY: + return NSRect( + x: bounds.minX, + y: bounds.maxY - compensation, + width: bounds.width, + height: compensation + ) + case .minY: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: compensation + ) + case .maxX: + return NSRect( + x: bounds.maxX - compensation, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + case .minX: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + @unknown default: + return bounds + } + } + } +} + +private struct SidebarFooterIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + SidebarFooterIconButtonStyleBody(configuration: configuration) + } +} + +private struct SidebarFooterIconButtonStyleBody: View { + let configuration: SidebarFooterIconButtonStyle.Configuration + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var backgroundOpacity: Double { + guard isEnabled else { return 0.0 } + if configuration.isPressed { return 0.16 } + if isHovered { return 0.08 } + return 0.0 + } + + var body: some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(backgroundOpacity)) + ) + .onHover { hovering in + isHovered = hovering + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} + #if DEBUG private struct SidebarDevFooter: View { @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner var body: some View { VStack(alignment: .leading, spacing: 6) { - UpdatePill(model: updateViewModel) - Text("THIS IS A DEV BUILD") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.red) + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + if showSidebarDevBuildBanner { + Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.red) + } } - .padding(.horizontal, 10) - .padding(.bottom, 10) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) } } #endif @@ -2398,14 +9327,14 @@ private struct SidebarEmptyArea: View { .contentShape(Rectangle()) .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture(count: 2) { - tabManager.addTab() + tabManager.addWorkspace(placementOverride: .end) if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } } selection = .tabs } - .onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate( + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( targetTabId: nil, tabManager: tabManager, draggedTabId: $draggedTabId, @@ -2418,7 +9347,7 @@ private struct SidebarEmptyArea: View { .overlay(alignment: .top) { if shouldShowTopDropIndicator { Rectangle() - .fill(Color.accentColor) + .fill(cmuxAccentColor()) .frame(height: 2) .padding(.horizontal, 8) .offset(y: -(rowSpacing / 2)) @@ -2436,16 +9365,115 @@ private struct SidebarEmptyArea: View { } } -private struct TabItemView: View { - @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var notificationStore: TerminalNotificationStore +enum SidebarPathFormatter { + static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path + + static func shortenedPath( + _ path: String, + homeDirectoryPath: String = Self.homeDirectoryPath + ) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == homeDirectoryPath { + return "~" + } + if trimmed.hasPrefix(homeDirectoryPath + "/") { + return "~" + trimmed.dropFirst(homeDirectoryPath.count) + } + return trimmed + } +} + +enum SidebarWorkspaceShortcutHintMetrics { + private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) + private static let minimumSlotWidth: CGFloat = 28 + private static let horizontalPadding: CGFloat = 12 + private static let lock = NSLock() + private static var cachedHintWidths: [String: CGFloat] = [:] + #if DEBUG + private static var measurementCount = 0 + #endif + + static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { + guard let label else { return minimumSlotWidth } + let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 + return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) + } + + static func hintWidth(for label: String) -> CGFloat { + lock.lock() + if let cached = cachedHintWidths[label] { + lock.unlock() + return cached + } + lock.unlock() + + let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width + let measuredWidth = ceil(textWidth) + horizontalPadding + + lock.lock() + cachedHintWidths[label] = measuredWidth + #if DEBUG + measurementCount += 1 + #endif + lock.unlock() + return measuredWidth + } + + #if DEBUG + static func resetCacheForTesting() { + lock.lock() + cachedHintWidths.removeAll() + measurementCount = 0 + lock.unlock() + } + + static func measurementCountForTesting() -> Int { + lock.lock() + let count = measurementCount + lock.unlock() + return count + } + #endif +} + +// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when +// the parent rebuilds with unchanged values. Without this, every TabManager +// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of +// main thread during typing). If you add new properties, update == below. +// Do NOT add @EnvironmentObject or new @Binding without updating ==. +// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. +private struct TabItemView: View, Equatable { + // Closures, Bindings, and object references are excluded from == + // because they're recreated every parent eval but don't affect rendering. + nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { + lhs.tab === rhs.tab && + lhs.index == rhs.index && + lhs.isActive == rhs.isActive && + lhs.tabCount == rhs.tabCount && + lhs.unreadCount == rhs.unreadCount && + lhs.latestNotificationText == rhs.latestNotificationText && + lhs.rowSpacing == rhs.rowSpacing && + lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints + } + + // Use plain references instead of @EnvironmentObject to avoid subscribing + // to ALL changes on these objects. Body reads use precomputed parameters; + // action handlers use the plain references without triggering re-evaluation. + let tabManager: TabManager + let notificationStore: TerminalNotificationStore + @Environment(\.colorScheme) private var colorScheme @ObservedObject var tab: Tab let index: Int + let isActive: Bool + let tabCount: Int + let unreadCount: Int + let latestNotificationText: String? let rowSpacing: CGFloat - @Binding var selection: SidebarSelection + let setSelectionToTabs: () -> Void @Binding var selectedTabIds: Set<UUID> @Binding var lastSidebarSelectionIndex: Int? - let showsCommandShortcutHints: Bool + let showsModifierShortcutHints: Bool let dragAutoScrollController: SidebarDragAutoScrollController @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? @@ -2456,15 +9484,17 @@ private struct TabItemView: View { @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout + @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true @AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false + @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true + @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true - @AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true - - var isActive: Bool { - tabManager.selectedTabId == tab.id - } + @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue var isMultiSelected: Bool { selectedTabIds.contains(tab.id) @@ -2474,12 +9504,75 @@ private struct TabItemView: View { draggedTabId == tab.id } + private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: activeTabIndicatorStyleRaw) + } + + private var titleFontWeight: Font.Weight { + .semibold + } + + private var showsLeadingRail: Bool { + explicitRailColor != nil + } + + private var activeBorderLineWidth: CGFloat { + switch activeTabIndicatorStyle { + case .leftRail: + return 0 + case .solidFill: + return isActive ? 1.5 : 0 + } + } + + private var activeBorderColor: Color { + guard isActive else { return .clear } + switch activeTabIndicatorStyle { + case .leftRail: + return .clear + case .solidFill: + return Color.primary.opacity(0.5) + } + } + + private var usesInvertedActiveForeground: Bool { + isActive + } + + private var activePrimaryTextColor: Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) + : .primary + } + + private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) + : .secondary + } + + private var activeUnreadBadgeFillColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.25) : cmuxAccentColor() + } + + private var activeProgressTrackColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) + } + + private var activeProgressFillColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.8) : cmuxAccentColor() + } + + private var shortcutHintEmphasis: Double { + usesInvertedActiveForeground ? 1.0 : 0.9 + } + private var workspaceShortcutDigit: Int? { - WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count) + WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabCount) } private var showCloseButton: Bool { - isHovering && tabManager.tabs.count > 1 && !(showsCommandShortcutHints || alwaysShowShortcutHints) + isHovering && tabCount > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints) } private var workspaceShortcutLabel: String? { @@ -2488,29 +9581,66 @@ private struct TabItemView: View { } private var showsWorkspaceShortcutHint: Bool { - (showsCommandShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil + (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil } private var workspaceHintSlotWidth: CGFloat { - guard let label = workspaceShortcutLabel else { return 28 } - let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) + 2 - return max(28, workspaceHintWidth(for: label) + positiveDebugInset) - } - - private func workspaceHintWidth(for label: String) -> CGFloat { - let font = NSFont.systemFont(ofSize: 10, weight: .semibold) - let textWidth = (label as NSString).size(withAttributes: [.font: font]).width - return ceil(textWidth) + 12 + SidebarWorkspaceShortcutHintMetrics.slotWidth( + label: workspaceShortcutLabel, + debugXOffset: sidebarShortcutHintXOffset + ) } var body: some View { + let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") + let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") + let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") + let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") + let latestNotificationSubtitle = latestNotificationText + let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest) + ? tab.sidebarOrderedPanelIds() + : nil + let compactGitBranchSummaryText: String? = { + guard sidebarShowBranchDirectory, + !sidebarBranchVerticalLayout, + sidebarShowGitBranch, + let orderedPanelIds else { + return nil + } + return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactDirectorySummaryText: String? = { + guard sidebarShowBranchDirectory, + !sidebarBranchVerticalLayout, + let orderedPanelIds else { + return nil + } + return directorySummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactBranchDirectoryRow = branchDirectoryRow( + gitSummary: compactGitBranchSummaryText, + directorySummary: compactDirectorySummaryText + ) + let branchDirectoryLines: [VerticalBranchDirectoryLine] = { + guard sidebarShowBranchDirectory, + sidebarBranchVerticalLayout, + let orderedPanelIds else { + return [] + } + return verticalBranchDirectoryLines(orderedPanelIds: orderedPanelIds) + }() + let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } + let pullRequestRows: [PullRequestDisplay] = { + guard sidebarShowPullRequest, let orderedPanelIds else { return [] } + return pullRequestDisplays(orderedPanelIds: orderedPanelIds) + }() + VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { - let unreadCount = notificationStore.unreadCount(forTabId: tab.id) if unreadCount > 0 { ZStack { Circle() - .fill(isActive ? Color.white.opacity(0.25) : Color.accentColor) + .fill(activeUnreadBadgeFillColor) Text("\(unreadCount)") .font(.system(size: 9, weight: .semibold)) .foregroundColor(.white) @@ -2521,12 +9651,12 @@ private struct TabItemView: View { if tab.isPinned { Image(systemName: "pin.fill") .font(.system(size: 9, weight: .semibold)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .foregroundColor(activeSecondaryColor(0.8)) } Text(tab.title) - .font(.system(size: 12.5, weight: .semibold)) - .foregroundColor(isActive ? .white : .primary) + .font(.system(size: 12.5, weight: titleFontWeight)) + .foregroundColor(activePrimaryTextColor) .lineLimit(1) .truncationMode(.tail) @@ -2541,10 +9671,10 @@ private struct TabItemView: View { }) { Image(systemName: "xmark") .font(.system(size: 9, weight: .medium)) - .foregroundColor(isActive ? .white.opacity(0.7) : .secondary) + .foregroundColor(activeSecondaryColor(0.7)) } .buttonStyle(.plain) - .help("Close Workspace (\(StoredShortcut(key: "w", command: true, shift: true, option: false, control: false).displayString))") + .safeHelp(KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip)) .frame(width: 16, height: 16, alignment: .center) .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) @@ -2555,10 +9685,10 @@ private struct TabItemView: View { .fixedSize(horizontal: true, vertical: false) .font(.system(size: 10, weight: .semibold, design: .rounded)) .monospacedDigit() - .foregroundColor(isActive ? .white : .primary) + .foregroundColor(activePrimaryTextColor) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(ShortcutHintPillBackground(emphasis: isActive ? 1.0 : 0.9)) + .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) .offset( x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) @@ -2566,29 +9696,38 @@ private struct TabItemView: View { .transition(.opacity) } } - .animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints) + .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) .frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing) } - if let subtitle = latestNotificationText { + if let subtitle = latestNotificationSubtitle { Text(subtitle) .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(2) .truncationMode(.tail) .multilineTextAlignment(.leading) } - if sidebarShowStatusPills, !tab.statusEntries.isEmpty { - SidebarStatusPillsRow( - entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in - if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } - return lhs.key < rhs.key - }), - isActive: isActive, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) + if sidebarShowMetadata { + let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + if !metadataEntries.isEmpty { + SidebarMetadataRows( + entries: metadataEntries, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + if !metadataBlocks.isEmpty { + SidebarMetadataMarkdownBlocks( + blocks: metadataBlocks, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } } // Latest log entry @@ -2596,10 +9735,10 @@ private struct TabItemView: View { HStack(spacing: 4) { Image(systemName: logLevelIcon(latestLog.level)) .font(.system(size: 8)) - .foregroundColor(logLevelColor(latestLog.level, isActive: isActive)) + .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) Text(latestLog.message) .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(1) .truncationMode(.tail) } @@ -2612,9 +9751,9 @@ private struct TabItemView: View { GeometryReader { geo in ZStack(alignment: .leading) { Capsule() - .fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2)) + .fill(activeProgressTrackColor) Capsule() - .fill(isActive ? Color.white.opacity(0.8) : Color.accentColor) + .fill(activeProgressFillColor) .frame(width: max(0, geo.size.width * CGFloat(progress.value))) } } @@ -2623,7 +9762,7 @@ private struct TabItemView: View { if let label = progress.label { Text(label) .font(.system(size: 9)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + .foregroundColor(activeSecondaryColor(0.6)) .lineLimit(1) } } @@ -2631,54 +9770,85 @@ private struct TabItemView: View { } // Branch + directory row - if sidebarBranchVerticalLayout { - if !verticalBranchDirectoryLines.isEmpty { - HStack(alignment: .top, spacing: 3) { - if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) - } - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in - HStack(spacing: 3) { - if let branch = line.branch { - Text(branch) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) - .lineLimit(1) - .truncationMode(.tail) - } - if line.branch != nil, line.directory != nil { - Image(systemName: "circle.fill") - .font(.system(size: 3)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) - .padding(.horizontal, 1) - } - if let directory = line.directory { - Text(directory) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) - .lineLimit(1) - .truncationMode(.tail) + if sidebarShowBranchDirectory { + if sidebarBranchVerticalLayout { + if !branchDirectoryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + if sidebarShowGitBranchIcon, branchLinesContainBranch { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in + HStack(spacing: 3) { + if let branch = line.branch { + Text(branch) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + if line.branch != nil, line.directory != nil { + Image(systemName: "circle.fill") + .font(.system(size: 3)) + .foregroundColor(activeSecondaryColor(0.6)) + .padding(.horizontal, 1) + } + if let directory = line.directory { + Text(directory) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } } } } } } - } - } else if let dirRow = branchDirectoryRow { - HStack(spacing: 3) { - if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + } else if let dirRow = compactBranchDirectoryRow { + HStack(spacing: 3) { + if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + Text(dirRow) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + // Pull request rows + if sidebarShowPullRequest, !pullRequestRows.isEmpty { + VStack(alignment: .leading, spacing: 1) { + ForEach(pullRequestRows) { pullRequest in + Button(action: { + openPullRequestLink(pullRequest.url) + }) { + HStack(spacing: 4) { + PullRequestStatusIcon( + status: pullRequest.status, + color: pullRequestForegroundColor + ) + Text("\(pullRequest.label) #\(pullRequest.number)") + .underline() + .lineLimit(1) + .truncationMode(.tail) + Text(pullRequestStatusLabel(pullRequest.status)) + .lineLimit(1) + Spacer(minLength: 0) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(pullRequestForegroundColor) + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) } - Text(dirRow) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) - .lineLimit(1) - .truncationMode(.tail) } } @@ -2686,18 +9856,33 @@ private struct TabItemView: View { if sidebarShowPorts, !tab.listeningPorts.isEmpty { Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", ")) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) } } .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) + .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count) .padding(.horizontal, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 6) .fill(backgroundColor) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) + } + .overlay(alignment: .leading) { + if showsLeadingRail { + Capsule(style: .continuous) + .fill(railColor) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + .offset(x: -1) + } + } ) .padding(.horizontal, 6) .background { @@ -2724,7 +9909,7 @@ private struct TabItemView: View { .overlay(alignment: .top) { if showsCenteredTopDropIndicator { Rectangle() - .fill(Color.accentColor) + .fill(cmuxAccentColor()) .frame(height: 2) .padding(.horizontal, 8) .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) @@ -2738,7 +9923,8 @@ private struct TabItemView: View { dropIndicator = nil return SidebarTabDragPayload.provider(for: tab.id) } - .onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate( + .internalOnlyTabDrag() + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( targetTabId: tab.id, tabManager: tabManager, draggedTabId: $draggedTabId, @@ -2748,6 +9934,12 @@ private struct TabItemView: View { dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) + .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( + targetWorkspaceId: tab.id, + tabManager: tabManager, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + )) .onTapGesture { updateSelection() } @@ -2756,103 +9948,236 @@ private struct TabItemView: View { } .accessibilityElement(children: .combine) .accessibilityLabel(Text(accessibilityTitle)) - .accessibilityHint(Text("Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.")) - .accessibilityAction(named: Text("Move Up")) { + .accessibilityHint(Text(accessibilityHintText)) + .accessibilityAction(named: Text(moveUpActionText)) { moveBy(-1) } - .accessibilityAction(named: Text("Move Down")) { + .accessibilityAction(named: Text(moveDownActionText)) { moveBy(1) } - .contextMenu { - let targetIds = contextTargetIds() - let shouldPin = !tab.isPinned - let pinLabel = targetIds.count > 1 - ? (shouldPin ? "Pin Workspaces" : "Unpin Workspaces") - : (shouldPin ? "Pin Workspace" : "Unpin Workspace") - let closeLabel = targetIds.count > 1 ? "Close Workspaces" : "Close Workspace" - let markReadLabel = targetIds.count > 1 ? "Mark Workspaces as Read" : "Mark Workspace as Read" - let markUnreadLabel = targetIds.count > 1 ? "Mark Workspaces as Unread" : "Mark Workspace as Unread" - Button(pinLabel) { - for id in targetIds { - if let tab = tabManager.tabs.first(where: { $0.id == id }) { - tabManager.setPinned(tab, pinned: shouldPin) - } - } - syncSelectionAfterMutation() - } + .contextMenu { workspaceContextMenu } + } - Button("Rename Workspace…") { + private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { + isMulti ? multi : single + } + + @ViewBuilder + private var workspaceContextMenu: some View { + let targetIds = contextTargetIds() + let isMulti = targetIds.count > 1 + let tabColorPalette = WorkspaceTabColorSettings.palette() + let shouldPin = !tab.isPinned + let pinLabel = shouldPin + ? contextMenuLabel( + multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), + single: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace"), + isMulti: isMulti) + : contextMenuLabel( + multi: String(localized: "contextMenu.unpinWorkspaces", defaultValue: "Unpin Workspaces"), + single: String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace"), + isMulti: isMulti) + let closeLabel = contextMenuLabel( + multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), + single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), + isMulti: isMulti) + let markReadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), + single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), + isMulti: isMulti) + let markUnreadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesUnread", defaultValue: "Mark Workspaces as Unread"), + single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), + isMulti: isMulti) + let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) + let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) + Button(pinLabel) { + for id in targetIds { + if let tab = tabManager.tabs.first(where: { $0.id == id }) { + tabManager.setPinned(tab, pinned: shouldPin) + } + } + syncSelectionAfterMutation() + } + + if let key = renameWorkspaceShortcut.keyEquivalent { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { promptRename() } + .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + promptRename() + } + } - if tab.hasCustomTitle { - Button("Remove Custom Workspace Name") { - tabManager.clearCustomTitle(tabId: tab.id) + if tab.hasCustomTitle { + Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { + tabManager.clearCustomTitle(tabId: tab.id) + } + } + + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { + if tab.customColor != nil { + Button { + applyTabColor(nil, targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color"), systemImage: "xmark.circle") } } - Divider() - - Button("Move Up") { - moveBy(-1) + Button { + promptCustomColor(targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") } - .disabled(index == 0) - Button("Move Down") { - moveBy(1) + if !tabColorPalette.isEmpty { + Divider() } - .disabled(index >= tabManager.tabs.count - 1) - Button("Move to Top") { - tabManager.moveTabsToTop(Set(targetIds)) - syncSelectionAfterMutation() + ForEach(tabColorPalette, id: \.id) { entry in + Button { + applyTabColor(entry.hex, targetIds: targetIds) + } label: { + Label { + Text(entry.name) + } icon: { + Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) + } + } + } + } + + Divider() + + Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { + moveBy(-1) + } + .disabled(index == 0) + + Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { + moveBy(1) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { + tabManager.moveTabsToTop(Set(targetIds)) + syncSelectionAfterMutation() + } + .disabled(targetIds.isEmpty) + + let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) + let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + let moveMenuTitle = targetIds.count > 1 + ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") + : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") + Menu(moveMenuTitle) { + Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { + moveWorkspacesToNewWindow(targetIds) } .disabled(targetIds.isEmpty) - Divider() + if !windowMoveTargets.isEmpty { + Divider() + } + ForEach(windowMoveTargets) { target in + Button(target.label) { + moveWorkspaces(targetIds, toWindow: target.windowId) + } + .disabled(target.isCurrentWindow || targetIds.isEmpty) + } + } + .disabled(targetIds.isEmpty) + + Divider() + + if let key = closeWorkspaceShortcut.keyEquivalent { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) + .disabled(targetIds.isEmpty) + } else { Button(closeLabel) { closeTabs(targetIds, allowPinned: true) } .disabled(targetIds.isEmpty) - - Button("Close Other Workspaces") { - closeOtherTabs(targetIds) - } - .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) - - Button("Close Workspaces Below") { - closeTabsBelow(tabId: tab.id) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button("Close Workspaces Above") { - closeTabsAbove(tabId: tab.id) - } - .disabled(index == 0) - - Divider() - - Button(markReadLabel) { - markTabsRead(targetIds) - } - .disabled(!hasUnreadNotifications(in: targetIds)) - - Button(markUnreadLabel) { - markTabsUnread(targetIds) - } - .disabled(!hasReadNotifications(in: targetIds)) } + + Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { + closeOtherTabs(targetIds) + } + .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) + + Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { + closeTabsBelow(tabId: tab.id) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { + closeTabsAbove(tabId: tab.id) + } + .disabled(index == 0) + + Divider() + + Button(markReadLabel) { + markTabsRead(targetIds) + } + .disabled(!hasUnreadNotifications(in: targetIds)) + + Button(markUnreadLabel) { + markTabsUnread(targetIds) + } + .disabled(!hasReadNotifications(in: targetIds)) } private var backgroundColor: Color { - if isActive { - return Color.accentColor + switch activeTabIndicatorStyle { + case .leftRail: + if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) } + if isMultiSelected { return cmuxAccentColor().opacity(0.25) } + return Color.clear + case .solidFill: + if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) } + if let custom = resolvedCustomTabColor { + if isMultiSelected { return custom.opacity(0.35) } + return custom.opacity(0.7) + } + if isMultiSelected { return cmuxAccentColor().opacity(0.25) } + return Color.clear } - if isMultiSelected { - return Color.accentColor.opacity(0.25) + } + + private var railColor: Color { + explicitRailColor ?? .clear + } + + private var explicitRailColor: Color? { + guard activeTabIndicatorStyle == .leftRail, + let custom = resolvedCustomTabColor else { + return nil } - return Color.clear + return custom.opacity(0.95) + } + + private var resolvedCustomTabColor: Color? { + guard let hex = tab.customColor else { return nil } + return WorkspaceTabColorSettings.displayColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) + } + + private func tabColorSwatchColor(for hex: String) -> NSColor { + WorkspaceTabColorSettings.displayNSColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) ?? NSColor(hex: hex) ?? .gray } private var showsCenteredTopDropIndicator: Bool { @@ -2871,7 +10196,7 @@ private struct TabItemView: View { } private var accessibilityTitle: String { - "\(tab.title), workspace \(index + 1) of \(tabManager.tabs.count)" + String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabCount)") } private func moveBy(_ delta: Int) { @@ -2881,7 +10206,7 @@ private struct TabItemView: View { selectedTabIds = [tab.id] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } tabManager.selectTab(tab) - selection = .tabs + setSelectionToTabs() } private func updateSelection() { @@ -2897,6 +10222,7 @@ private struct TabItemView: View { let modifiers = NSEvent.modifierFlags let isCommand = modifiers.contains(.command) let isShift = modifiers.contains(.shift) + let wasSelected = tabManager.selectedTabId == tab.id if isShift, let lastIndex = lastSidebarSelectionIndex { let lower = min(lastIndex, index) @@ -2919,7 +10245,13 @@ private struct TabItemView: View { lastSidebarSelectionIndex = index tabManager.selectTab(tab) - selection = .tabs + if wasSelected, !isCommand, !isShift { + tabManager.dismissNotificationOnDirectInteraction( + tabId: tab.id, + surfaceId: tabManager.focusedSurfaceId(for: tab.id) + ) + } + setSelectionToTabs() } private func contextTargetIds() -> [UUID] { @@ -2992,58 +10324,85 @@ private struct TabItemView: View { } } - private var latestNotificationText: String? { - guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil } - let text = notification.body.isEmpty ? notification.title : notification.body - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard !orderedWorkspaceIds.isEmpty else { return } + + for (index, workspaceId) in orderedWorkspaceIds.enumerated() { + let shouldFocus = index == orderedWorkspaceIds.count - 1 + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() } - private var branchDirectoryRow: String? { + private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } + + let shouldFocusImmediately = orderedWorkspaceIds.count == 1 + guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { + return + } + + if orderedWorkspaceIds.count > 1 { + for workspaceId in orderedWorkspaceIds.dropFirst() { + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) + } + if let finalWorkspaceId = orderedWorkspaceIds.last { + _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) + } + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + // latestNotificationText is now passed as a parameter from the parent view + // to avoid subscribing to notificationStore changes in every TabItemView. + + private func branchDirectoryRow( + gitSummary: String?, + directorySummary: String? + ) -> String? { var parts: [String] = [] - // Git branch (if enabled and available) - if sidebarShowGitBranch, let gitSummary = gitBranchSummaryText { + if let gitSummary { parts.append(gitSummary) } - // Directory summary - if let dirs = directorySummaryText { - parts.append(dirs) + if let directorySummary { + parts.append(directorySummary) } let result = parts.joined(separator: " · ") return result.isEmpty ? nil : result } - private var gitBranchSummaryText: String? { - let lines = gitBranchSummaryLines + private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { + let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) guard !lines.isEmpty else { return nil } return lines.joined(separator: " | ") } - private var gitBranchSummaryLines: [String] { - tab.sidebarGitBranchesInDisplayOrder().map { branch in + private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { + tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in "\(branch.branch)\(branch.isDirty ? "*" : "")" } } - private var verticalBranchDirectoryEntries: [SidebarBranchOrdering.BranchDirectoryEntry] { - tab.sidebarBranchDirectoryEntriesInDisplayOrder() - } - - private var verticalRowsContainBranch: Bool { - sidebarShowGitBranch && verticalBranchDirectoryLines.contains { $0.branch != nil } - } - private struct VerticalBranchDirectoryLine { let branch: String? let directory: String? } - private var verticalBranchDirectoryLines: [VerticalBranchDirectoryLine] { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return verticalBranchDirectoryEntries.compactMap { entry in + private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { + let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) + let home = SidebarPathFormatter.homeDirectoryPath + return entries.compactMap { entry in let branchText: String? = { guard sidebarShowGitBranch, let branch = entry.branch else { return nil } return "\(branch)\(entry.isDirty ? "*" : "")" @@ -3051,7 +10410,7 @@ private struct TabItemView: View { let directoryText: String? = { guard let directory = entry.directory else { return nil } - let shortened = shortenPath(directory, home: home) + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) return shortened.isEmpty ? nil : shortened }() @@ -3068,14 +10427,14 @@ private struct TabItemView: View { } } - private var directorySummaryText: String? { + private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { guard !tab.panels.isEmpty else { return nil } - let home = FileManager.default.homeDirectoryForCurrentUser.path + let home = SidebarPathFormatter.homeDirectoryPath var seen: Set<String> = [] var entries: [String] = [] - for panelId in tab.sidebarOrderedPanelIds() { + for panelId in orderedPanelIds { let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory - let shortened = shortenPath(directory, home: home) + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) guard !shortened.isEmpty else { continue } if seen.insert(shortened).inserted { entries.append(shortened) @@ -3084,6 +10443,54 @@ private struct TabItemView: View { return entries.isEmpty ? nil : entries.joined(separator: " | ") } + private struct PullRequestDisplay: Identifiable { + let id: String + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus + } + + private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { + tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in + PullRequestDisplay( + id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status + ) + } + } + + private var pullRequestForegroundColor: Color { + isActive ? .white.opacity(0.75) : .secondary + } + + private func openPullRequestLink(_ url: URL) { + updateSelection() + if openSidebarPullRequestLinksInCmuxBrowser { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + + private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String { + switch status { + case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") + case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") + case .closed: return String(localized: "sidebar.pullRequest.statusClosed", defaultValue: "closed") + } + } + private func logLevelIcon(_ level: SidebarLogLevel) -> String { switch level { case .info: return "circle.fill" @@ -3097,11 +10504,16 @@ private struct TabItemView: View { private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { if isActive { switch level { - case .info: return .white.opacity(0.5) - case .progress: return .white.opacity(0.8) - case .success: return .white.opacity(0.9) - case .warning: return .white.opacity(0.9) - case .error: return .white.opacity(0.9) + case .info: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) + case .progress: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) + case .success: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .warning: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .error: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) } } switch level { @@ -3113,28 +10525,160 @@ private struct TabItemView: View { } } - private func shortenPath(_ path: String, home: String) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return path } - if trimmed == home { - return "~" + private struct PullRequestStatusIcon: View { + let status: SidebarPullRequestStatus + let color: Color + private static let frameSize: CGFloat = 12 + + var body: some View { + switch status { + case .open: + PullRequestOpenIcon(color: color) + case .merged: + PullRequestMergedIcon(color: color) + case .closed: + Image(systemName: "xmark.circle") + .font(.system(size: 7, weight: .regular)) + .foregroundColor(color) + .frame(width: Self.frameSize, height: Self.frameSize) + } } - if trimmed.hasPrefix(home + "/") { - return "~" + trimmed.dropFirst(home.count) + } + + private struct PullRequestOpenIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 3.0, y: 4.8)) + path.addLine(to: CGPoint(x: 3.0, y: 9.2)) + + path.move(to: CGPoint(x: 4.8, y: 3.0)) + path.addLine(to: CGPoint(x: 9.4, y: 3.0)) + path.addLine(to: CGPoint(x: 11.0, y: 4.6)) + path.addLine(to: CGPoint(x: 11.0, y: 9.2)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 11.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) } - return trimmed + } + + private struct PullRequestMergedIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 4.6, y: 4.6)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + path.addLine(to: CGPoint(x: 9.2, y: 7.0)) + + path.move(to: CGPoint(x: 4.6, y: 9.4)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 7.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private func applyTabColor(_ hex: String?, targetIds: [UUID]) { + for targetId in targetIds { + tabManager.setTabColor(tabId: targetId, color: hex) + } + } + + private func promptCustomColor(targetIds: [UUID]) { + let alert = NSAlert() + alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") + alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") + + let seed = tab.customColor ?? WorkspaceTabColorSettings.customColors().first ?? "" + let input = NSTextField(string: seed) + input.placeholderString = "#1565C0" + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.customColor.apply", defaultValue: "Apply")) + alert.addButton(withTitle: String(localized: "alert.customColor.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { + showInvalidColorAlert(input.stringValue) + return + } + applyTabColor(normalized, targetIds: targetIds) + } + + private func showInvalidColorAlert(_ value: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") + } else { + alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") + } + alert.addButton(withTitle: String(localized: "alert.invalidColor.ok", defaultValue: "OK")) + _ = alert.runModal() } private func promptRename() { let alert = NSAlert() - alert.messageText = "Rename Workspace" - alert.informativeText = "Enter a custom name for this workspace." + alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") let input = NSTextField(string: tab.customTitle ?? tab.title) - input.placeholderString = "Workspace name" + input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input - alert.addButton(withTitle: "Rename") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) let alertWindow = alert.window alertWindow.initialFirstResponder = input DispatchQueue.main.async { @@ -3147,33 +10691,175 @@ private struct TabItemView: View { } } -private struct SidebarStatusPillsRow: View { +private struct SidebarMetadataRows: View { let entries: [SidebarStatusEntry] let isActive: Bool let onFocus: () -> Void @State private var isExpanded: Bool = false + private let collapsedEntryLimit = 3 var body: some View { VStack(alignment: .leading, spacing: 2) { - Text(statusText) - .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) - .lineLimit(isExpanded ? nil : 3) - .truncationMode(.tail) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { + ForEach(visibleEntries, id: \.key) { entry in + SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { onFocus() - guard shouldShowToggle else { return } withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() } } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .safeHelp(helpText) + } + + private var activeSecondaryTextColor: Color { + Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) + } + + private var visibleEntries: [SidebarStatusEntry] { + guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } + return Array(entries.prefix(collapsedEntryLimit)) + } + + private var helpText: String { + entries.map { entry in + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? entry.key : trimmed + } + .joined(separator: "\n") + } + + private var shouldShowToggle: Bool { + entries.count > collapsedEntryLimit + } +} + +private struct SidebarMetadataEntryRow: View { + let entry: SidebarStatusEntry + let isActive: Bool + let onFocus: () -> Void + + var body: some View { + Group { + if let url = entry.url { + Button { + onFocus() + NSWorkspace.shared.open(url) + } label: { + rowContent(underlined: true) + } + .buttonStyle(.plain) + .safeHelp(url.absoluteString) + } else { + rowContent(underlined: false) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + } + } + } + + @ViewBuilder + private func rowContent(underlined: Bool) -> some View { + HStack(spacing: 4) { + if let icon = iconView { + icon + .foregroundColor(foregroundColor.opacity(0.95)) + } + metadataText(underlined: underlined) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .font(.system(size: 10)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var foregroundColor: Color { + if isActive, + let raw = entry.color, + Color(hex: raw) != nil { + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) + } + if let raw = entry.color, let explicit = Color(hex: raw) { + return explicit + } + return isActive ? .white.opacity(0.8) : .secondary + } + + private var iconView: AnyView? { + guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), + !iconRaw.isEmpty else { + return nil + } + if iconRaw.hasPrefix("emoji:") { + let value = String(iconRaw.dropFirst("emoji:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 9))) + } + if iconRaw.hasPrefix("text:") { + let value = String(iconRaw.dropFirst("text:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) + } + let symbolName: String + if iconRaw.hasPrefix("sf:") { + symbolName = String(iconRaw.dropFirst("sf:".count)) + } else { + symbolName = iconRaw + } + guard !symbolName.isEmpty else { return nil } + return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) + } + + @ViewBuilder + private func metadataText(underlined: Bool) -> some View { + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + let display = trimmed.isEmpty ? entry.key : trimmed + if entry.format == .markdown, + let attributed = try? AttributedString( + markdown: display, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + Text(attributed) + .underline(underlined) + .foregroundColor(foregroundColor) + } else { + Text(display) + .underline(underlined) + .foregroundColor(foregroundColor) + } + } +} + +private struct SidebarMetadataMarkdownBlocks: View { + let blocks: [SidebarMetadataBlock] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedBlockLimit = 1 + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(visibleBlocks, id: \.key) { block in + SidebarMetadataMarkdownBlockRow( + block: block, + isActive: isActive, + onFocus: onFocus + ) + } if shouldShowToggle { - Button(isExpanded ? "Show less" : "Show more") { + Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { onFocus() withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() @@ -3185,21 +10871,55 @@ private struct SidebarStatusPillsRow: View { .frame(maxWidth: .infinity, alignment: .leading) } } - .help(statusText) } - private var statusText: String { - entries - .map { entry in - let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { return value } - return entry.key - } - .joined(separator: "\n") + private var visibleBlocks: [SidebarMetadataBlock] { + guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } + return Array(blocks.prefix(collapsedBlockLimit)) } private var shouldShowToggle: Bool { - entries.count > 1 || statusText.count > 120 + blocks.count > collapsedBlockLimit + } +} + +private struct SidebarMetadataMarkdownBlockRow: View { + let block: SidebarMetadataBlock + let isActive: Bool + let onFocus: () -> Void + + @State private var renderedMarkdown: AttributedString? + + var body: some View { + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + .foregroundColor(foregroundColor) + } else { + Text(block.markdown) + .foregroundColor(foregroundColor) + } + } + .font(.system(size: 10)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + .onAppear(perform: renderMarkdown) + .onChange(of: block.markdown) { _ in + renderMarkdown() + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.8) : .secondary + } + + private func renderMarkdown() { + renderedMarkdown = try? AttributedString( + markdown: block.markdown, + options: .init(interpretedSyntax: .full) + ) } } @@ -3474,6 +11194,8 @@ private final class SidebarDragAutoScrollController: ObservableObject { private enum SidebarTabDragPayload { static let typeIdentifier = "com.cmux.sidebar-tab-reorder" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] private static let prefix = "cmux.sidebar-tab." static func provider(for tabId: UUID) -> NSItemProvider { @@ -3487,6 +11209,113 @@ private enum SidebarTabDragPayload { } } +private enum BonsplitTabDragPayload { + static let typeIdentifier = "com.splittabbar.tabtransfer" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) + + struct Transfer: Decodable { + struct TabInfo: Decodable { + let id: UUID + } + + let tab: TabInfo + let sourcePaneId: UUID + let sourceProcessId: Int32 + + private enum CodingKeys: String, CodingKey { + case tab + case sourcePaneId + case sourceProcessId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.tab = try container.decode(TabInfo.self, forKey: .tab) + self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) + // Legacy payloads won't include this field. Treat as foreign process. + self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 + } + } + + private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { + transfer.sourceProcessId == currentProcessId + } + + static func currentTransfer() -> Transfer? { + let pasteboard = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType(typeIdentifier) + + if let data = pasteboard.data(forType: type), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + if let raw = pasteboard.string(forType: type), + let data = raw.data(using: .utf8), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + return nil + } +} + +private struct SidebarBonsplitTabDropDelegate: DropDelegate { + let targetWorkspaceId: UUID + let tabManager: TabManager + @Binding var selectedTabIds: Set<UUID> + @Binding var lastSidebarSelectionIndex: Int? + + func validateDrop(info: DropInfo) -> Bool { + guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } + return BonsplitTabDragPayload.currentTransfer() != nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info), + let transfer = BonsplitTabDragPayload.currentTransfer(), + let app = AppDelegate.shared else { + return false + } + + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == targetWorkspaceId { + syncSidebarSelection() + return true + } + + guard app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: targetWorkspaceId, + focus: true, + focusWindow: true + ) else { + return false + } + + selectedTabIds = [targetWorkspaceId] + syncSidebarSelection() + return true + } + + private func syncSidebarSelection() { + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } +} + private struct SidebarTabDropDelegate: DropDelegate { let targetTabId: UUID? let tabManager: TabManager @@ -3621,28 +11450,6 @@ private struct SidebarTabDropDelegate: DropDelegate { } } -/// AppKit-level double-click handler for the sidebar title-bar area. -/// Uses NSView hit-testing so it isn't swallowed by the SwiftUI ScrollView underneath. -private struct DoubleClickZoomView: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - DoubleClickZoomNSView() - } - - func updateNSView(_ nsView: NSView, context: Context) {} - - private final class DoubleClickZoomNSView: NSView { - override var mouseDownCanMoveWindow: Bool { true } - override func hitTest(_ point: NSPoint) -> NSView? { self } - override func mouseDown(with event: NSEvent) { - if event.clickCount == 2 { - window?.zoom(nil) - } else { - super.mouseDown(with: event) - } - } - } -} - private struct MiddleClickCapture: NSViewRepresentable { let onMiddleClick: () -> Void @@ -3745,7 +11552,7 @@ private struct DraggableFolderIcon: View { var body: some View { DraggableFolderIconRepresentable(directory: directory) .frame(width: 16, height: 16) - .help("Drag to open in Finder or another app") + .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) .onTapGesture(count: 2) { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) } @@ -3765,9 +11572,21 @@ private struct DraggableFolderIconRepresentable: NSViewRepresentable { } } -private final class DraggableFolderNSView: NSView, NSDraggingSource { +final class DraggableFolderNSView: NSView, NSDraggingSource { + private final class FolderIconImageView: NSImageView { + override var mouseDownCanMoveWindow: Bool { false } + } + var directory: String - private var imageView: NSImageView! + private var imageView: FolderIconImageView! + private var previousWindowMovableState: Bool? + private weak var suppressedWindow: NSWindow? + private var hasActiveDragSession = false + private var didArmWindowDragSuppression = false + + private func formatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) + } init(directory: String) { self.directory = directory @@ -3783,8 +11602,10 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { NSSize(width: 16, height: 16) } + override var mouseDownCanMoveWindow: Bool { false } + private func setupImageView() { - imageView = NSImageView() + imageView = FolderIconImageView() imageView.imageScaling = .scaleProportionallyDown imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) @@ -3809,9 +11630,39 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { return context == .outsideApplication ? [.copy, .link] : .copy } - override func mouseDown(with event: NSEvent) { + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + hasActiveDragSession = false + restoreWindowMovableStateIfNeeded() #if DEBUG - dlog("folder.dragStart dir=\(directory)") + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + let hit = super.hitTest(point) + #if DEBUG + let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" + let imageHit = (hit === imageView) + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") + #endif + return self + } + + override func mouseDown(with event: NSEvent) { + maybeDisableWindowDraggingEarly(trigger: "mouseDown") + hasActiveDragSession = false + #if DEBUG + let localPoint = convert(event.locationInWindow, from: nil) + let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") #endif let fileURL = URL(fileURLWithPath: directory) let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) @@ -3820,7 +11671,19 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { iconImage.size = NSSize(width: 32, height: 32) draggingItem.setDraggingFrame(bounds, contents: iconImage) - beginDraggingSession(with: [draggingItem], event: event, source: self) + let session = beginDraggingSession(with: [draggingItem], event: event, source: self) + hasActiveDragSession = true + #if DEBUG + let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 + dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") + #endif + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + // Always restore suppression on mouse-up; drag-session callbacks can be + // skipped for non-started drags, which would otherwise leave suppression stuck. + restoreWindowMovableStateIfNeeded() } override func rightMouseDown(with event: NSEvent) { @@ -3854,7 +11717,7 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { displayName = volumeName } else { - displayName = "Macintosh HD" + displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") } } else { displayName = FileManager.default.displayName(atPath: pathURL.path) @@ -3889,6 +11752,59 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { // Open "Computer" view in Finder (shows all volumes) NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) } + + private func restoreWindowMovableStateIfNeeded() { + guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } + let targetWindow = suppressedWindow ?? window + let depthAfter = endWindowDragSuppression(window: targetWindow) + restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) + self.previousWindowMovableState = nil + self.suppressedWindow = nil + self.didArmWindowDragSuppression = false + #if DEBUG + let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" + dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") + #endif + } + + private func maybeDisableWindowDraggingEarly(trigger: String) { + guard !didArmWindowDragSuppression else { return } + guard let eventType = NSApp.currentEvent?.type, + eventType == .leftMouseDown || eventType == .leftMouseDragged else { + return + } + guard let currentWindow = window else { return } + + didArmWindowDragSuppression = true + suppressedWindow = currentWindow + let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 + if currentWindow.isMovable { + previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) + } else { + previousWindowMovableState = nil + } + #if DEBUG + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = String(currentWindow.isMovable) + dlog( + "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" + ) + #endif + } +} + +func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { + guard let window else { return nil } + let wasMovable = window.isMovable + if wasMovable { + window.isMovable = false + } + return wasMovable +} + +func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { + guard let window, let previousMovableState else { return } + window.isMovable = previousMovableState } /// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested @@ -3970,11 +11886,16 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { /// Reads the leading inset required to clear traffic lights + left titlebar accessories. +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + private struct TitlebarLeadingInsetReader: NSViewRepresentable { @Binding var inset: CGFloat func makeNSView(context: Context) -> NSView { - let view = NSView() + let view = TitlebarLeadingInsetPassthroughView() view.setFrameSize(.zero) return view } @@ -4060,19 +11981,19 @@ enum SidebarMaterialOption: String, CaseIterable, Identifiable { var title: String { switch self { - case .none: return "None" - case .liquidGlass: return "Liquid Glass (macOS 26+)" - case .sidebar: return "Sidebar" - case .hudWindow: return "HUD Window" - case .menu: return "Menu" - case .popover: return "Popover" - case .underWindowBackground: return "Under Window" - case .windowBackground: return "Window Background" - case .contentBackground: return "Content Background" - case .fullScreenUI: return "Full Screen UI" - case .sheet: return "Sheet" - case .headerView: return "Header View" - case .toolTip: return "Tool Tip" + case .none: return String(localized: "settings.material.none", defaultValue: "None") + case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") + case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") + case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") + case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") + case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") + case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") + case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") + case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") + case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") + case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") + case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") + case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") } } @@ -4108,8 +12029,8 @@ enum SidebarBlendModeOption: String, CaseIterable, Identifiable { var title: String { switch self { - case .behindWindow: return "Behind Window" - case .withinWindow: return "Within Window" + case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") + case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") } } @@ -4130,9 +12051,9 @@ enum SidebarStateOption: String, CaseIterable, Identifiable { var title: String { switch self { - case .active: return "Active" - case .inactive: return "Inactive" - case .followWindow: return "Follow Window" + case .active: return String(localized: "settings.state.active", defaultValue: "Active") + case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") + case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") } } @@ -4157,12 +12078,12 @@ enum SidebarPresetOption: String, CaseIterable, Identifiable { var title: String { switch self { - case .nativeSidebar: return "Native Sidebar" - case .glassBehind: return "Raycast Gray" - case .softBlur: return "Soft Blur" - case .popoverGlass: return "Popover Glass" - case .hudGlass: return "HUD Glass" - case .underWindow: return "Under Window" + case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") + case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") + case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") + case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") + case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") + case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") } } @@ -4245,18 +12166,20 @@ enum SidebarPresetOption: String, CaseIterable, Identifiable { } extension NSColor { - func hexString() -> String { + func hexString(includeAlpha: Bool = false) -> String { let color = usingColorSpace(.sRGB) ?? self var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - return String( - format: "#%02X%02X%02X", - min(255, max(0, Int(red * 255))), - min(255, max(0, Int(green * 255))), - min(255, max(0, Int(blue * 255))) - ) + let redByte = min(255, max(0, Int(red * 255))) + let greenByte = min(255, max(0, Int(green * 255))) + let blueByte = min(255, max(0, Int(blue * 255))) + if includeAlpha { + let alphaByte = min(255, max(0, Int(alpha * 255))) + return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) + } + return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) } } diff --git a/Sources/Find/BrowserFindJavaScript.swift b/Sources/Find/BrowserFindJavaScript.swift new file mode 100644 index 00000000..c664bdc6 --- /dev/null +++ b/Sources/Find/BrowserFindJavaScript.swift @@ -0,0 +1,207 @@ +import Foundation + +/// JavaScript snippets for find-in-page in WKWebView. +/// +/// Uses TreeWalker to scan text nodes and wraps matches with `<mark>` elements. +/// The current match gets an additional `.current` class and is scrolled into view. +enum BrowserFindJavaScript { + + // MARK: - Public API + + /// Returns JS that highlights all occurrences of `query` in the document body. + /// The script evaluates to a JSON string `{"total":N,"current":0}`. + static func searchScript(query: String) -> String { + let escaped = jsStringEscape(query) + return """ + (() => { + const MARK_CLASS = '__cmux-find'; + const CURRENT_CLASS = '__cmux-find-current'; + + // Remove previous highlights first. + \(clearBody) + + const query = "\(escaped)"; + if (!query) return JSON.stringify({total: 0, current: 0}); + + const lowerQuery = query.toLowerCase(); + const SKIP_TAGS = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE','IFRAME','SVG']); + const isVisible = (el) => { + while (el && el !== document.body) { + if (SKIP_TAGS.has(el.tagName)) return false; + if (el.getAttribute('aria-hidden') === 'true') return false; + const st = getComputedStyle(el); + if (st.display === 'none' || st.visibility === 'hidden') return false; + el = el.parentElement; + } + return true; + }; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { acceptNode(node) { return isVisible(node.parentElement) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } } + ); + const matches = []; + const textNodes = []; + while (walker.nextNode()) textNodes.push(walker.currentNode); + + for (const node of textNodes) { + const text = node.textContent || ''; + const lowerText = text.toLowerCase(); + let startIndex = 0; + const parts = []; + let lastEnd = 0; + while (true) { + const idx = lowerText.indexOf(lowerQuery, startIndex); + if (idx === -1) break; + parts.push({ start: idx, end: idx + query.length }); + startIndex = idx + query.length; + } + if (parts.length === 0) continue; + + const parent = node.parentNode; + if (!parent) continue; + const frag = document.createDocumentFragment(); + let pos = 0; + for (const part of parts) { + if (part.start > pos) { + frag.appendChild(document.createTextNode(text.substring(pos, part.start))); + } + const mark = document.createElement('mark'); + mark.className = MARK_CLASS; + mark.textContent = text.substring(part.start, part.end); + frag.appendChild(mark); + matches.push(mark); + pos = part.end; + } + if (pos < text.length) { + frag.appendChild(document.createTextNode(text.substring(pos))); + } + parent.replaceChild(frag, node); + } + + window.__cmuxFindMatches = matches; + window.__cmuxFindIndex = 0; + + if (matches.length > 0) { + matches[0].classList.add(CURRENT_CLASS); + matches[0].scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + + // Inject highlight styles if not already present. + if (!document.getElementById('__cmux-find-style')) { + const style = document.createElement('style'); + style.id = '__cmux-find-style'; + style.textContent = ` + mark.__cmux-find { background: #facc15; color: #000; border-radius: 2px; } + mark.__cmux-find.__cmux-find-current { background: #f97316; color: #fff; } + `; + document.head.appendChild(style); + } + + return JSON.stringify({ total: matches.length, current: 0 }); + })() + """ + } + + /// Returns JS that moves to the next match. Evaluates to `{"total":N,"current":M}`. + static func nextScript() -> String { + """ + (() => { + const matches = window.__cmuxFindMatches || []; + if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 }); + let idx = window.__cmuxFindIndex || 0; + if (!matches[idx] || !matches[idx].isConnected) { + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + return JSON.stringify({ total: 0, current: 0 }); + } + matches[idx].classList.remove('__cmux-find-current'); + idx = (idx + 1) % matches.length; + if (!matches[idx] || !matches[idx].isConnected) { + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + return JSON.stringify({ total: 0, current: 0 }); + } + matches[idx].classList.add('__cmux-find-current'); + matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' }); + window.__cmuxFindIndex = idx; + return JSON.stringify({ total: matches.length, current: idx }); + })() + """ + } + + /// Returns JS that moves to the previous match. Evaluates to `{"total":N,"current":M}`. + static func previousScript() -> String { + """ + (() => { + const matches = window.__cmuxFindMatches || []; + if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 }); + let idx = window.__cmuxFindIndex || 0; + if (!matches[idx] || !matches[idx].isConnected) { + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + return JSON.stringify({ total: 0, current: 0 }); + } + matches[idx].classList.remove('__cmux-find-current'); + idx = (idx - 1 + matches.length) % matches.length; + if (!matches[idx] || !matches[idx].isConnected) { + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + return JSON.stringify({ total: 0, current: 0 }); + } + matches[idx].classList.add('__cmux-find-current'); + matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' }); + window.__cmuxFindIndex = idx; + return JSON.stringify({ total: matches.length, current: idx }); + })() + """ + } + + /// Returns JS that removes all find highlights and restores the DOM. + static func clearScript() -> String { + """ + (() => { + \(clearBody) + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + const style = document.getElementById('__cmux-find-style'); + if (style) style.remove(); + return 'ok'; + })() + """ + } + + // MARK: - Internal + + /// JS snippet (no wrapping IIFE) that removes existing mark highlights. + private static let clearBody = """ + document.querySelectorAll('mark.__cmux-find').forEach(mark => { + const parent = mark.parentNode; + if (!parent) return; + const text = document.createTextNode(mark.textContent || ''); + parent.replaceChild(text, mark); + parent.normalize(); + }); + """ + + /// Escape a Swift string for safe embedding inside a JS double-quoted string literal. + static func jsStringEscape(_ string: String) -> String { + var result = "" + result.reserveCapacity(string.count) + for scalar in string.unicodeScalars { + switch scalar { + case "\\": result += "\\\\" + case "\"": result += "\\\"" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + case "\0": result += "\\0" + case "\u{2028}": result += "\\u2028" + case "\u{2029}": result += "\\u2029" + default: + result.append(Character(scalar)) + } + } + return result + } +} diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift new file mode 100644 index 00000000..5fde1163 --- /dev/null +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -0,0 +1,251 @@ +import AppKit +import Bonsplit +import SwiftUI + +struct BrowserSearchOverlay: View { + let panelId: UUID + @ObservedObject var searchState: BrowserSearchState + let focusRequestGeneration: UInt64 + let canApplyFocusRequest: (UInt64) -> Bool + let onNext: () -> Void + let onPrevious: () -> Void + let onClose: () -> Void + let onFieldDidFocus: () -> Void + @State private var corner: Corner = .topRight + @State private var dragOffset: CGSize = .zero + @State private var barSize: CGSize = .zero + @FocusState private var isSearchFieldFocused: Bool + + private let padding: CGFloat = 8 + +#if DEBUG + private func debugFirstResponderSummary() -> String { + guard let window = NSApp.keyWindow else { return "nil" } + guard let firstResponder = window.firstResponder else { return "nil" } + if let editor = firstResponder as? NSTextView, editor.isFieldEditor { + let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil" + return "fieldEditor(delegate=\(delegateSummary))" + } + return String(describing: type(of: firstResponder)) + } +#endif + + private func logFocusState(_ event: String) { +#if DEBUG + let keyWindow = NSApp.keyWindow + dlog( + "browser.findbar.focus panel=\(panelId.uuidString.prefix(5)) " + + "event=\(event) keyWindow=\(keyWindow?.windowNumber ?? -1) " + + "firstResponder=\(debugFirstResponderSummary()) " + + "focused=\(isSearchFieldFocused ? 1 : 0)" + ) +#endif + } + + private func requestSearchFieldFocus(maxAttempts: Int = 3, origin: String) { + guard maxAttempts > 0 else { return } + guard canApplyFocusRequest(focusRequestGeneration) else { +#if DEBUG + logFocusState("request.skip origin=\(origin) generation=\(focusRequestGeneration)") +#endif + return + } + logFocusState("request.begin origin=\(origin) remaining=\(maxAttempts)") + isSearchFieldFocused = true +#if DEBUG + DispatchQueue.main.async { + guard canApplyFocusRequest(focusRequestGeneration) else { + logFocusState("request.skipAsync origin=\(origin) generation=\(focusRequestGeneration)") + return + } + logFocusState("request.afterAsync origin=\(origin) remaining=\(maxAttempts)") + } +#endif + guard maxAttempts > 1 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + guard canApplyFocusRequest(focusRequestGeneration) else { +#if DEBUG + logFocusState("request.skipRetry origin=\(origin) generation=\(focusRequestGeneration)") +#endif + return + } + requestSearchFieldFocus(maxAttempts: maxAttempts - 1, origin: origin) + } + } + + var body: some View { + GeometryReader { geo in + HStack(spacing: 4) { + TextField("Search", text: $searchState.needle) + .textFieldStyle(.plain) + .accessibilityIdentifier("BrowserFindSearchTextField") + .frame(width: 180) + .padding(.leading, 8) + .padding(.trailing, 50) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + .focused($isSearchFieldFocused) + .overlay(alignment: .trailing) { + if let selected = searchState.selected { + let totalText = searchState.total.map { String($0) } ?? "?" + Text("\(selected + 1)/\(totalText)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } else if let total = searchState.total { + Text(total == 0 ? "0/0" : "-/\(total)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } + } + .onExitCommand { + onClose() + } + .onSubmit { + // onSubmit fires only after IME composition is committed. + if NSEvent.modifierFlags.contains(.shift) { + onPrevious() + } else { + onNext() + } + } + + Button(action: { + #if DEBUG + dlog("browser.findbar.next panel=\(panelId.uuidString.prefix(5))") + #endif + onNext() + }) { + Image(systemName: "chevron.up") + } + .buttonStyle(SearchButtonStyle()) + .safeHelp("Next match (Return)") + + Button(action: { + #if DEBUG + dlog("browser.findbar.prev panel=\(panelId.uuidString.prefix(5))") + #endif + onPrevious() + }) { + Image(systemName: "chevron.down") + } + .buttonStyle(SearchButtonStyle()) + .safeHelp("Previous match (Shift+Return)") + + Button(action: { + #if DEBUG + dlog("browser.findbar.close panel=\(panelId.uuidString.prefix(5))") + #endif + onClose() + }) { + Image(systemName: "xmark") + } + .buttonStyle(SearchButtonStyle()) + .safeHelp("Close (Esc)") + } + .padding(8) + .background(.background) + .clipShape(clipShape) + .shadow(radius: 4) + .onAppear { +#if DEBUG + dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))") +#endif + logFocusState("appear") + requestSearchFieldFocus(origin: "appear") + } + .onChange(of: isSearchFieldFocused) { _, focused in + logFocusState("focusState.change next=\(focused ? 1 : 0)") + if focused { + onFieldDidFocus() + } + } + .onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in + guard let notifiedPanelId = notification.object as? UUID, + notifiedPanelId == panelId else { return } + logFocusState("notification.received") + DispatchQueue.main.async { + requestSearchFieldFocus(origin: "notification") + } + } + .background( + GeometryReader { barGeo in + Color.clear.onAppear { + barSize = barGeo.size + } + } + ) + .padding(padding) + .offset(dragOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) + .gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation + } + .onEnded { value in + let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize) + let newCenter = CGPoint( + x: centerPos.x + value.translation.width, + y: centerPos.y + value.translation.height + ) + let newCorner = closestCorner(to: newCenter, in: geo.size) + withAnimation(.easeOut(duration: 0.2)) { + corner = newCorner + dragOffset = .zero + } + } + ) + } + } + + private var clipShape: some Shape { + RoundedRectangle(cornerRadius: 8) + } + + enum Corner { + case topLeft + case topRight + case bottomLeft + case bottomRight + + var alignment: Alignment { + switch self { + case .topLeft: return .topLeading + case .topRight: return .topTrailing + case .bottomLeft: return .bottomLeading + case .bottomRight: return .bottomTrailing + } + } + } + + private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint { + let halfWidth = barSize.width / 2 + padding + let halfHeight = barSize.height / 2 + padding + + switch corner { + case .topLeft: + return CGPoint(x: halfWidth, y: halfHeight) + case .topRight: + return CGPoint(x: containerSize.width - halfWidth, y: halfHeight) + case .bottomLeft: + return CGPoint(x: halfWidth, y: containerSize.height - halfHeight) + case .bottomRight: + return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) + } + } + + private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner { + let midX = containerSize.width / 2 + let midY = containerSize.height / 2 + + if point.x < midX { + return point.y < midY ? .topLeft : .bottomLeft + } + return point.y < midY ? .topRight : .bottomRight + } +} diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index d8bf5463..f6ad9a40 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -1,30 +1,68 @@ +import AppKit import Bonsplit import SwiftUI +private extension NSView { + func cmuxAncestor<T: NSView>(of type: T.Type) -> T? { + var current: NSView? = self + while let view = current { + if let target = view as? T { + return target + } + current = view.superview + } + return nil + } +} + 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 onFieldDidFocus: () -> Void let onClose: () -> Void @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero - @FocusState private var isSearchFieldFocused: Bool + @State private var isSearchFieldFocused: Bool = true private let padding: CGFloat = 8 var body: some View { GeometryReader { geo in HStack(spacing: 4) { - TextField("Search", text: $searchState.needle) - .textFieldStyle(.plain) - .frame(width: 180) - .padding(.leading, 8) - .padding(.trailing, 50) - .padding(.vertical, 6) - .background(Color.primary.opacity(0.1)) - .cornerRadius(6) - .focused($isSearchFieldFocused) - .overlay(alignment: .trailing) { + SearchTextFieldRepresentable( + text: $searchState.needle, + isFocused: $isSearchFieldFocused, + surfaceId: surfaceId, + onFieldDidFocus: onFieldDidFocus, + onEscape: { + #if DEBUG + dlog("find.nativeField.escape surface=\(surfaceId.uuidString.prefix(5)) needleEmpty=\(searchState.needle.isEmpty)") + #endif + if searchState.needle.isEmpty { + onClose() + } else { + onMoveFocusToTerminal() + } + }, + onReturn: { isShift in + let action = isShift + ? "navigate_search:previous" + : "navigate_search:next" + onNavigateSearch(action) + } + ) + .accessibilityIdentifier("TerminalFindSearchTextField") + .frame(width: 180) + .padding(.leading, 8) + .padding(.trailing, 50) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + .overlay(alignment: .trailing) { if let selected = searchState.selected { let totalText = searchState.total.map { String($0) } ?? "?" Text("\(selected + 1)/\(totalText)") @@ -40,69 +78,50 @@ struct SurfaceSearchOverlay: View { .padding(.trailing, 8) } } - .onExitCommand { - if searchState.needle.isEmpty { - onClose() - } else { - surface.hostedView.moveFocus() - } - } - .backport.onKeyPress(.return) { modifiers in - let action = modifiers.contains(.shift) - ? "navigate_search:previous" - : "navigate_search:next" - _ = surface.performBindingAction(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") } .buttonStyle(SearchButtonStyle()) - .help("Next match (Return)") + .safeHelp(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)")) 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") } .buttonStyle(SearchButtonStyle()) - .help("Previous match (Shift+Return)") + .safeHelp(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)")) Button(action: { #if DEBUG - dlog("findbar.close surface=\(surface.id.uuidString.prefix(5))") + dlog("findbar.close surface=\(surfaceId.uuidString.prefix(5))") #endif onClose() }) { Image(systemName: "xmark") } .buttonStyle(SearchButtonStyle()) - .help("Close (Esc)") + .safeHelp(String(localized: "search.close.help", defaultValue: "Close (Esc)")) } .padding(8) .background(.background) .clipShape(clipShape) .shadow(radius: 4) .onAppear { - NSLog("Find: overlay appear tab=%@ surface=%@", surface.tabId.uuidString, surface.id.uuidString) + #if DEBUG + dlog("find.overlay.appear tab=\(tabId.uuidString.prefix(5)) surface=\(surfaceId.uuidString.prefix(5))") + #endif 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) - DispatchQueue.main.async { - isSearchFieldFocused = true - } - } .background( GeometryReader { barGeo in Color.clear.onAppear { @@ -181,6 +200,203 @@ struct SurfaceSearchOverlay: View { } } +// MARK: - Native Search Text Field (AppKit) + +/// NSTextField subclass for the terminal find bar. +/// Strips visual chrome so SwiftUI handles the background/border appearance. +private final class SearchNativeTextField: NSTextField { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isBordered = false + isBezeled = false + drawsBackground = false + focusRingType = .none + usesSingleLineMode = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// NSViewRepresentable wrapping SearchNativeTextField. +/// Handles Escape and Return at the AppKit delegate level, eliminating the +/// SwiftUI @FocusState / AppKit first-responder mismatch that broke focus +/// after window switching. +private struct SearchTextFieldRepresentable: NSViewRepresentable { + @Binding var text: String + @Binding var isFocused: Bool + let surfaceId: UUID + let onFieldDidFocus: () -> Void + let onEscape: () -> Void + let onReturn: (_ isShift: Bool) -> Void + + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: SearchTextFieldRepresentable + var isProgrammaticMutation = false + weak var parentField: SearchNativeTextField? + var pendingFocusRequest: Bool? + var searchFocusObserver: NSObjectProtocol? + + init(parent: SearchTextFieldRepresentable) { + self.parent = parent + } + + deinit { + if let searchFocusObserver { + NotificationCenter.default.removeObserver(searchFocusObserver) + } + } + + func controlTextDidChange(_ obj: Notification) { + guard !isProgrammaticMutation else { return } + guard let field = obj.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func controlTextDidBeginEditing(_ obj: Notification) { + #if DEBUG + dlog("find.nativeField.beginEditing surface=\(parent.surfaceId.uuidString.prefix(5))") + #endif + parent.onFieldDidFocus() + if !parent.isFocused { + DispatchQueue.main.async { + self.parent.isFocused = true + } + } + } + + func controlTextDidEndEditing(_ obj: Notification) { + #if DEBUG + dlog("find.nativeField.endEditing surface=\(parent.surfaceId.uuidString.prefix(5))") + #endif + if parent.isFocused { + DispatchQueue.main.async { + self.parent.isFocused = false + } + } + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + switch commandSelector { + case #selector(NSResponder.cancelOperation(_:)): + // Don't intercept Escape during CJK IME composition (issue #118) + if textView.hasMarkedText() { return false } + control.cmuxAncestor(of: GhosttySurfaceScrollView.self)?.beginFindEscapeSuppression() + parent.onEscape() + return true + case #selector(NSResponder.insertNewline(_:)): + if textView.hasMarkedText() { return false } + let isShift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + parent.onReturn(isShift) + return true + default: + return false + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> SearchNativeTextField { + let field = SearchNativeTextField(frame: .zero) + field.font = .systemFont(ofSize: NSFont.systemFontSize) + field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search") + field.setAccessibilityIdentifier("TerminalFindSearchTextField") + field.delegate = context.coordinator + field.stringValue = text + context.coordinator.parentField = field + + // Observe .ghosttySearchFocus to immediately focus from AppKit level. + // This is the primary mechanism for restoring focus after window switches. + context.coordinator.searchFocusObserver = NotificationCenter.default.addObserver( + forName: .ghosttySearchFocus, + object: nil, + queue: .main + ) { [weak field, weak coordinator = context.coordinator] notification in + guard let field, let coordinator else { return } + guard let surface = notification.object as? TerminalSurface, + surface.id == coordinator.parent.surfaceId else { return } + guard let window = field.window else { return } + // Don't re-focus if already first responder. makeFirstResponder on an + // already-editing NSTextField ends the editing session and restarts it + // with all text selected, causing typed characters to replace each other. + let fr = window.firstResponder + let alreadyFocused = fr === field || + field.currentEditor() != nil || + ((fr as? NSTextView)?.delegate as? NSTextField) === field + #if DEBUG + dlog( + "find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + + "alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: fr))" + ) + #endif + guard !alreadyFocused else { return } + let result = window.makeFirstResponder(field) +#if DEBUG + dlog( + "find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" + ) +#endif + } + + return field + } + + func updateNSView(_ nsView: SearchNativeTextField, context: Context) { + context.coordinator.parent = self + context.coordinator.parentField = nsView + + // Sync text from binding to field (skip during active IME composition) + if let editor = nsView.currentEditor() as? NSTextView { + if editor.string != text, !editor.hasMarkedText() { + context.coordinator.isProgrammaticMutation = true + editor.string = text + nsView.stringValue = text + context.coordinator.isProgrammaticMutation = false + } + } else if nsView.stringValue != text { + nsView.stringValue = text + } + + // Sync focus from binding to AppKit + if let window = nsView.window { + let fr = window.firstResponder + let isFirstResponder = + fr === nsView || + nsView.currentEditor() != nil || + ((fr as? NSTextView)?.delegate as? NSTextField) === nsView + + if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true { + context.coordinator.pendingFocusRequest = true + DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in + coordinator?.pendingFocusRequest = nil + guard let coordinator, coordinator.parent.isFocused else { return } + guard let nsView, let window = nsView.window else { return } + let fr = window.firstResponder + let alreadyFocused = fr === nsView || + nsView.currentEditor() != nil || + ((fr as? NSTextView)?.delegate as? NSTextField) === nsView + guard !alreadyFocused else { return } + window.makeFirstResponder(nsView) + } + } + } + } + + static func dismantleNSView(_ nsView: SearchNativeTextField, coordinator: Coordinator) { + if let observer = coordinator.searchFocusObserver { + NotificationCenter.default.removeObserver(observer) + coordinator.searchFocusObserver = nil + } + nsView.delegate = nil + coordinator.parentField = nil + } +} + struct SearchButtonStyle: ButtonStyle { @State private var isHovered = false diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 0e8fafa9..1e3aae49 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -2,11 +2,14 @@ import Foundation import AppKit struct GhosttyConfig { - enum ColorSchemePreference { + enum ColorSchemePreference: Hashable { case light case dark } + private static let loadCacheLock = NSLock() + private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:] + var fontFamily: String = "Menlo" var fontSize: CGFloat = 12 var theme: String? @@ -18,6 +21,7 @@ struct GhosttyConfig { // Colors (from theme or config) var backgroundColor: NSColor = NSColor(hex: "#272822")! + var backgroundOpacity: Double = 1.0 var foregroundColor: NSColor = NSColor(hex: "#fdfff1")! var cursorColor: NSColor = NSColor(hex: "#c0c1b5")! var cursorTextColor: NSColor = NSColor(hex: "#8d8e82")! @@ -45,7 +49,45 @@ struct GhosttyConfig { return backgroundColor.darken(by: isLightBackground ? 0.08 : 0.4) } - static func load() -> GhosttyConfig { + static func load( + preferredColorScheme: ColorSchemePreference? = nil, + useCache: Bool = true, + loadFromDisk: (_ preferredColorScheme: ColorSchemePreference) -> GhosttyConfig = Self.loadFromDisk + ) -> GhosttyConfig { + let resolvedColorScheme = preferredColorScheme ?? currentColorSchemePreference() + if useCache, let cached = cachedLoad(for: resolvedColorScheme) { + return cached + } + + let loaded = loadFromDisk(resolvedColorScheme) + if useCache { + storeCachedLoad(loaded, for: resolvedColorScheme) + } + return loaded + } + + static func invalidateLoadCache() { + loadCacheLock.lock() + cachedConfigsByColorScheme.removeAll() + loadCacheLock.unlock() + } + + private static func cachedLoad(for colorScheme: ColorSchemePreference) -> GhosttyConfig? { + loadCacheLock.lock() + defer { loadCacheLock.unlock() } + return cachedConfigsByColorScheme[colorScheme] + } + + private static func storeCachedLoad( + _ config: GhosttyConfig, + for colorScheme: ColorSchemePreference + ) { + loadCacheLock.lock() + cachedConfigsByColorScheme[colorScheme] = config + loadCacheLock.unlock() + } + + private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig { var config = GhosttyConfig() // Match Ghostty's default load order on macOS. @@ -64,7 +106,12 @@ struct GhosttyConfig { // Load theme if specified if let themeName = config.theme { - config.loadTheme(themeName) + config.loadTheme( + themeName, + environment: ProcessInfo.processInfo.environment, + bundleResourceURL: Bundle.main.resourceURL, + preferredColorScheme: preferredColorScheme + ) } return config @@ -102,6 +149,10 @@ struct GhosttyConfig { if let color = NSColor(hex: value) { backgroundColor = color } + case "background-opacity": + if let opacity = Double(value) { + backgroundOpacity = opacity + } case "foreground": if let color = NSColor(hex: value) { foregroundColor = color diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 78121fa0..65e50eaa 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10,12 +10,22 @@ import Bonsplit import IOSurface #if os(macOS) -private func cmuxShouldUseTransparentBackgroundWindow() -> Bool { +func cmuxShouldUseTransparentBackgroundWindow() -> Bool { let defaults = UserDefaults.standard let sidebarBlendMode = defaults.string(forKey: "sidebarBlendMode") ?? "withinWindow" - let bgGlassEnabled = defaults.object(forKey: "bgGlassEnabled") as? Bool ?? true + let bgGlassEnabled = defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false return sidebarBlendMode == "behindWindow" && bgGlassEnabled && !WindowGlassEffect.isAvailable } + +func cmuxShouldUseClearWindowBackground(for opacity: Double) -> Bool { + cmuxShouldUseTransparentBackgroundWindow() || opacity < 0.999 +} + +private func cmuxTransparentWindowBaseColor() -> NSColor { + // A tiny non-zero alpha matches Ghostty's window compositing behavior on macOS and + // avoids visual artifacts that can happen with a fully clear window background. + NSColor.white.withAlphaComponent(0.001) +} #endif #if DEBUG @@ -94,7 +104,8 @@ private enum GhosttyPasteboardHelper { static func hasString(for location: ghostty_clipboard_e) -> Bool { guard let pasteboard = pasteboard(for: location) else { return false } - return (stringContents(from: pasteboard) ?? "").isEmpty == false + if let text = stringContents(from: pasteboard), !text.isEmpty { return true } + return clipboardHasImageOnly() } static func writeString(_ string: String, to location: ghostty_clipboard_e) { @@ -103,13 +114,70 @@ private enum GhosttyPasteboardHelper { pasteboard.setString(string, forType: .string) } - private static func escapeForShell(_ value: String) -> String { + static func escapeForShell(_ value: String) -> String { var result = value for char in shellEscapeCharacters { result = result.replacingOccurrences(of: String(char), with: "\\\(char)") } return result } + + private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB + + /// Quick check: does the clipboard have image data and no text? + static func clipboardHasImageOnly() -> Bool { + let pb = NSPasteboard.general + let types = pb.types ?? [] + let hasText = types.contains(.string) || types.contains(.html) + || types.contains(.rtf) || types.contains(.rtfd) + if hasText { return false } + return types.contains(.tiff) || types.contains(.png) + } + + /// When the clipboard contains only image data (no text/HTML), saves it as + /// a temporary PNG file and returns the shell-escaped file path. Returns nil + /// if the clipboard contains text or no image. + static func saveClipboardImageIfNeeded() -> String? { + let pb = NSPasteboard.general + let types = pb.types ?? [] + + // If pasteboard has text/HTML, this is a normal copy. + let hasText = types.contains(.string) || types.contains(.html) + || types.contains(.rtf) || types.contains(.rtfd) + if hasText { return nil } + + // Check for image types (TIFF from screenshots, PNG from some tools). + guard types.contains(.tiff) || types.contains(.png) else { return nil } + guard let image = NSImage(pasteboard: pb), + let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } + + guard pngData.count <= maxClipboardImageSize else { +#if DEBUG + dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.count)") +#endif + return nil + } + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + formatter.locale = Locale(identifier: "en_US_POSIX") + let timestamp = formatter.string(from: Date()) + let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png" + let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename) + + do { + try pngData.write(to: URL(fileURLWithPath: path)) + } catch { +#if DEBUG + dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") +#endif + return nil + } + + return escapeForShell(path) + } } enum TerminalOpenURLTarget: Equatable { @@ -124,11 +192,95 @@ enum TerminalOpenURLTarget: Equatable { } } +enum GhosttyDefaultBackgroundUpdateScope: Int { + case unscoped = 0 + case app = 1 + case surface = 2 + + var logLabel: String { + switch self { + case .unscoped: return "unscoped" + case .app: return "app" + case .surface: return "surface" + } + } +} + +/// Coalesces Ghostty background notifications so consumers only observe +/// the latest runtime background for a burst of updates. +final class GhosttyDefaultBackgroundNotificationDispatcher { + private let coalescer: NotificationBurstCoalescer + private let postNotification: ([AnyHashable: Any]) -> Void + private var pendingUserInfo: [AnyHashable: Any]? + private var pendingEventId: UInt64 = 0 + private var pendingSource: String = "unspecified" + private let logEvent: ((String) -> Void)? + + init( + delay: TimeInterval = 1.0 / 30.0, + logEvent: ((String) -> Void)? = nil, + postNotification: @escaping ([AnyHashable: Any]) -> Void = { userInfo in + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: userInfo + ) + } + ) { + coalescer = NotificationBurstCoalescer(delay: delay) + self.logEvent = logEvent + self.postNotification = postNotification + } + + func signal(backgroundColor: NSColor, opacity: Double, eventId: UInt64, source: String) { + let signalOnMain = { [self] in + pendingEventId = eventId + pendingSource = source + pendingUserInfo = [ + GhosttyNotificationKey.backgroundColor: backgroundColor, + GhosttyNotificationKey.backgroundOpacity: opacity, + GhosttyNotificationKey.backgroundEventId: NSNumber(value: eventId), + GhosttyNotificationKey.backgroundSource: source + ] + logEvent?( + "bg notify queued id=\(eventId) source=\(source) color=\(backgroundColor.hexString()) opacity=\(String(format: "%.3f", opacity))" + ) + coalescer.signal { [self] in + guard let userInfo = pendingUserInfo else { return } + let eventId = pendingEventId + let source = pendingSource + pendingUserInfo = nil + logEvent?("bg notify flushed id=\(eventId) source=\(source)") + logEvent?("bg notify posting id=\(eventId) source=\(source)") + postNotification(userInfo) + logEvent?("bg notify posted id=\(eventId) source=\(source)") + } + } + + if Thread.isMainThread { + signalOnMain() + } else { + DispatchQueue.main.async(execute: signalOnMain) + } + } +} + func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } + #if DEBUG + dlog("link.resolve input=\(trimmed)") + #endif + guard !trimmed.isEmpty else { + #if DEBUG + dlog("link.resolve result=nil (empty)") + #endif + return nil + } if NSString(string: trimmed).isAbsolutePath { + #if DEBUG + dlog("link.resolve result=external(absolutePath) url=\(trimmed)") + #endif return .external(URL(fileURLWithPath: trimmed)) } @@ -136,24 +288,368 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? let scheme = parsed.scheme?.lowercased() { if scheme == "http" || scheme == "https" { guard BrowserInsecureHTTPSettings.normalizeHost(parsed.host ?? "") != nil else { + #if DEBUG + dlog("link.resolve result=external(invalidHost) url=\(parsed)") + #endif return .external(parsed) } + #if DEBUG + dlog("link.resolve result=embeddedBrowser url=\(parsed)") + #endif return .embeddedBrowser(parsed) } + #if DEBUG + dlog("link.resolve result=external(scheme=\(scheme)) url=\(parsed)") + #endif return .external(parsed) } if let webURL = resolveBrowserNavigableURL(trimmed) { guard BrowserInsecureHTTPSettings.normalizeHost(webURL.host ?? "") != nil else { + #if DEBUG + dlog("link.resolve result=external(bareHost-invalidHost) url=\(webURL)") + #endif return .external(webURL) } + #if DEBUG + dlog("link.resolve result=embeddedBrowser(bareHost) url=\(webURL)") + #endif return .embeddedBrowser(webURL) } - guard let fallback = URL(string: trimmed) else { return nil } + guard let fallback = URL(string: trimmed) else { + #if DEBUG + dlog("link.resolve result=nil (unparseable)") + #endif + return nil + } + #if DEBUG + dlog("link.resolve result=external(fallback) url=\(fallback)") + #endif return .external(fallback) } +enum TerminalKeyboardCopyModeSelectionMove: String, Equatable { + case left + case right + case up + case down + case pageUp = "page_up" + case pageDown = "page_down" + case home + case end + case beginningOfLine = "beginning_of_line" + case endOfLine = "end_of_line" +} + +enum TerminalKeyboardCopyModeAction: Equatable { + case exit + case startSelection + case clearSelection + case copyAndExit + case copyLineAndExit + case scrollLines(Int) + case scrollPage(Int) + case scrollHalfPage(Int) + case scrollToTop + case scrollToBottom + case jumpToPrompt(Int) + case startSearch + case searchNext + case searchPrevious + case adjustSelection(TerminalKeyboardCopyModeSelectionMove) +} + +struct TerminalKeyboardCopyModeInputState: Equatable { + var countPrefix: Int? + var pendingYankLine = false + var pendingG = false + + mutating func reset() { + countPrefix = nil + pendingYankLine = false + pendingG = false + } +} + +enum TerminalKeyboardCopyModeResolution: Equatable { + case perform(TerminalKeyboardCopyModeAction, count: Int) + case consume +} + +private let terminalKeyboardCopyModeMaxCount = 9_999 + +private var terminalKeyboardCopyModeIndicatorText: String { + String(localized: "ghostty.copy-mode.indicator", defaultValue: "vim") +} + +private var terminalKeyTableIndicatorDefaultText: String { + String(localized: "ghostty.key-table.indicator", defaultValue: "key table") +} + +private var terminalKeyTableIndicatorAccessibilityLabel: String { + String(localized: "ghostty.key-table.icon.accessibility", defaultValue: "Key table") +} + +private func terminalKeyboardCopyModeClampCount(_ value: Int) -> Int { + min(max(value, 1), terminalKeyboardCopyModeMaxCount) +} + +private func terminalKeyTableIndicatorText(_ name: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + switch trimmed.lowercased() { + case "", "set": + return terminalKeyTableIndicatorDefaultText + case "vi", "vim": + return terminalKeyboardCopyModeIndicatorText + default: + let normalized = trimmed + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? terminalKeyTableIndicatorDefaultText : normalized + } +} + +func terminalKeyboardCopyModeInitialViewportRow( + rows: Int, + imePointY: Double, + imeCellHeight: Double, + topPadding: Double = 0 +) -> Int { + let clampedRows = max(rows, 1) + guard imeCellHeight > 0 else { return clampedRows - 1 } + + // `ghostty_surface_ime_point` returns a top-origin Y coordinate at the + // cursor baseline plus one cell-height. Convert that to a zero-based row. + let estimatedRow = Int(floor(((imePointY - topPadding) / imeCellHeight) - 1)) + return max(0, min(clampedRows - 1, estimatedRow)) +} + +private func terminalKeyboardCopyModeNormalizedModifiers( + _ modifierFlags: NSEvent.ModifierFlags +) -> NSEvent.ModifierFlags { + modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) +} + +private func terminalKeyboardCopyModeChars( + _ charactersIgnoringModifiers: String? +) -> String { + guard let scalar = charactersIgnoringModifiers?.unicodeScalars.first else { + return "" + } + return String(scalar).lowercased() +} + +func terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: NSEvent.ModifierFlags) -> Bool { + let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) + return normalized.contains(.command) +} + +func terminalKeyboardCopyModeAction( + keyCode: UInt16, + charactersIgnoringModifiers: String?, + modifierFlags: NSEvent.ModifierFlags, + hasSelection: Bool +) -> TerminalKeyboardCopyModeAction? { + let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) + let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers) + + if keyCode == 53 { // Escape + return .exit + } + + switch keyCode { + case 126: // Up + return hasSelection ? .adjustSelection(.up) : .scrollLines(-1) + case 125: // Down + return hasSelection ? .adjustSelection(.down) : .scrollLines(1) + case 123: // Left + return hasSelection ? .adjustSelection(.left) : nil + case 124: // Right + return hasSelection ? .adjustSelection(.right) : nil + case 116: // Page Up + return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1) + case 121: // Page Down + return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1) + case 115: // Home + return hasSelection ? .adjustSelection(.home) : .scrollToTop + case 119: // End + return hasSelection ? .adjustSelection(.end) : .scrollToBottom + default: + break + } + + if normalized == [.control] { + if chars == "u" || chars == "\u{15}" { + return hasSelection ? .adjustSelection(.pageUp) : .scrollHalfPage(-1) + } + if chars == "d" || chars == "\u{04}" { + return hasSelection ? .adjustSelection(.pageDown) : .scrollHalfPage(1) + } + if chars == "b" || chars == "\u{02}" { + return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1) + } + if chars == "f" || chars == "\u{06}" { + return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1) + } + if chars == "y" || chars == "\u{19}" { + return hasSelection ? .adjustSelection(.up) : .scrollLines(-1) + } + if chars == "e" || chars == "\u{05}" { + return hasSelection ? .adjustSelection(.down) : .scrollLines(1) + } + return nil + } + + guard normalized.isEmpty || normalized == [.shift] else { return nil } + + switch chars { + case "q": + return .exit + case "v": + return hasSelection ? .clearSelection : .startSelection + case "y": + if normalized == [.shift], !hasSelection { + return .copyLineAndExit + } + return hasSelection ? .copyAndExit : nil + case "j": + return hasSelection ? .adjustSelection(.down) : .scrollLines(1) + case "k": + return hasSelection ? .adjustSelection(.up) : .scrollLines(-1) + case "h": + return hasSelection ? .adjustSelection(.left) : nil + case "l": + return hasSelection ? .adjustSelection(.right) : nil + case "g": + if normalized == [.shift] { + return hasSelection ? .adjustSelection(.end) : .scrollToBottom + } + // Bare "g" is a prefix key (e.g. gg); handled in resolve. + return nil + case "0", "^": + return hasSelection ? .adjustSelection(.beginningOfLine) : nil + case "$", "4": + guard chars == "$" || normalized == [.shift] else { return nil } + return hasSelection ? .adjustSelection(.endOfLine) : nil + case "{", "[": + guard chars == "{" || normalized == [.shift] else { return nil } + return .jumpToPrompt(-1) + case "}", "]": + guard chars == "}" || normalized == [.shift] else { return nil } + return .jumpToPrompt(1) + case "/": + return .startSearch + case "n": + return normalized == [.shift] ? .searchPrevious : .searchNext + default: + return nil + } +} + +func terminalKeyboardCopyModeResolve( + keyCode: UInt16, + charactersIgnoringModifiers: String?, + modifierFlags: NSEvent.ModifierFlags, + hasSelection: Bool, + state: inout TerminalKeyboardCopyModeInputState +) -> TerminalKeyboardCopyModeResolution { + let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) + let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers) + + if keyCode == 53 { // Escape + state.reset() + return .perform(.exit, count: 1) + } + + if state.pendingYankLine { + if chars == "y", normalized.isEmpty || normalized == [.shift] { + let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1) + state.reset() + return .perform(.copyLineAndExit, count: count) + } + // Only `yy`/`Y` are supported as line-yank operators, so cancel the + // pending yank and treat this key as a fresh command. + state.pendingYankLine = false + } + + if state.pendingG { + if chars == "g", normalized.isEmpty { + let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1) + let action: TerminalKeyboardCopyModeAction = hasSelection ? .adjustSelection(.home) : .scrollToTop + state.reset() + return .perform(action, count: count) + } + // Not `gg`, cancel and treat as fresh command. + state.pendingG = false + } + + if normalized.isEmpty, + let scalar = chars.unicodeScalars.first, + scalar.isASCII, + scalar.value >= 48, + scalar.value <= 57 { + let digit = Int(scalar.value - 48) + if digit == 0 { + if let currentCount = state.countPrefix { + state.countPrefix = terminalKeyboardCopyModeClampCount(currentCount * 10) + return .consume + } + } else { + let currentCount = state.countPrefix ?? 0 + state.countPrefix = terminalKeyboardCopyModeClampCount((currentCount * 10) + digit) + return .consume + } + } + + if !hasSelection, chars == "y", normalized.isEmpty { + state.pendingYankLine = true + return .consume + } + + if chars == "g", normalized.isEmpty { + state.pendingG = true + return .consume + } + + guard let action = terminalKeyboardCopyModeAction( + keyCode: keyCode, + charactersIgnoringModifiers: charactersIgnoringModifiers, + modifierFlags: modifierFlags, + hasSelection: hasSelection + ) else { + state.reset() + return .consume + } + + let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1) + state.reset() + return .perform(action, count: count) +} + +private final class GhosttySurfaceCallbackContext { + weak var surfaceView: GhosttyNSView? + weak var terminalSurface: TerminalSurface? + let surfaceId: UUID + + init(surfaceView: GhosttyNSView, terminalSurface: TerminalSurface) { + self.surfaceView = surfaceView + self.terminalSurface = terminalSurface + self.surfaceId = terminalSurface.id + } + + var tabId: UUID? { + terminalSurface?.tabId ?? surfaceView?.tabId + } + + var runtimeSurface: ghostty_surface_t? { + terminalSurface?.surface ?? surfaceView?.terminalSurface?.surface + } +} + // Minimal Ghostty wrapper for terminal rendering // This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation @@ -161,15 +657,44 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? class GhosttyApp { static let shared = GhosttyApp() + private static let releaseBundleIdentifier = "com.cmuxterm.app" + private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() private(set) var app: ghostty_app_t? private(set) var config: ghostty_config_t? private(set) var defaultBackgroundColor: NSColor = .windowBackgroundColor private(set) var defaultBackgroundOpacity: Double = 1.0 + private static func resolveBackgroundLogURL( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> URL { + if let explicitPath = environment["CMUX_DEBUG_BG_LOG"], + !explicitPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: explicitPath) + } + + if let debugLogPath = environment["CMUX_DEBUG_LOG"], + !debugLogPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let baseURL = URL(fileURLWithPath: debugLogPath) + let extensionSeparatorIndex = baseURL.lastPathComponent.lastIndex(of: ".") + let stem = extensionSeparatorIndex.map { String(baseURL.lastPathComponent[..<$0]) } ?? baseURL.lastPathComponent + let bgName = "\(stem)-bg.log" + return baseURL.deletingLastPathComponent().appendingPathComponent(bgName) + } + + return URL(fileURLWithPath: "/tmp/cmux-bg.log") + } + let backgroundLogEnabled = { if ProcessInfo.processInfo.environment["CMUX_DEBUG_BG"] == "1" { return true } + if ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"] != nil { + return true + } if ProcessInfo.processInfo.environment["GHOSTTYTABS_DEBUG_BG"] == "1" { return true } @@ -178,15 +703,34 @@ class GhosttyApp { } return UserDefaults.standard.bool(forKey: "GhosttyTabsDebugBG") }() - private let backgroundLogURL = URL(fileURLWithPath: "/tmp/cmux-bg.log") + private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() + private let backgroundLogStartUptime = ProcessInfo.processInfo.systemUptime + private let backgroundLogLock = NSLock() + private var backgroundLogSequence: UInt64 = 0 private var appObservers: [NSObjectProtocol] = [] + private var bellAudioSound: NSSound? + private var backgroundEventCounter: UInt64 = 0 + private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped + private var defaultBackgroundScopeSource: String = "initialize" + private var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference? + private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher = + // Theme chrome should track terminal theme changes in the same frame. + // Keep coalescing semantics, but flush in the next main turn instead of waiting ~1 frame. + GhosttyDefaultBackgroundNotificationDispatcher(delay: 0, logEvent: { [weak self] message in + guard let self, self.backgroundLogEnabled else { return } + self.logBackground(message) + }) // Scroll lag tracking private(set) var isScrolling = false private var scrollLagSampleCount = 0 private var scrollLagTotalMs: Double = 0 private var scrollLagMaxMs: Double = 0 - private let scrollLagThresholdMs: Double = 25 // Alert if tick takes >25ms during scroll + private let scrollLagThresholdMs: Double = 40 + private let scrollLagMinimumSamples = 8 + private let scrollLagMinimumAverageMs: Double = 12 + private let scrollLagReportCooldownSeconds: TimeInterval = 300 + private var lastScrollLagReportUptime: TimeInterval? private var scrollEndTimer: DispatchWorkItem? func markScrollActivity(hasMomentum: Bool, momentumEnded: Bool) { @@ -221,16 +765,30 @@ class GhosttyApp { let maxLag = scrollLagMaxMs let samples = scrollLagSampleCount let threshold = scrollLagThresholdMs - if maxLag > threshold { - SentrySDK.capture(message: "Scroll lag detected") { scope in - scope.setLevel(.warning) - scope.setContext(value: [ - "samples": samples, - "avg_ms": String(format: "%.2f", avgLag), - "max_ms": String(format: "%.2f", maxLag), - "threshold_ms": threshold - ], key: "scroll_lag") + let nowUptime = ProcessInfo.processInfo.systemUptime + if Self.shouldCaptureScrollLagEvent( + samples: samples, + averageMs: avgLag, + maxMs: maxLag, + thresholdMs: threshold, + minimumSamples: scrollLagMinimumSamples, + minimumAverageMs: scrollLagMinimumAverageMs, + nowUptime: nowUptime, + lastReportedUptime: lastScrollLagReportUptime, + cooldown: scrollLagReportCooldownSeconds + ) { + if TelemetrySettings.enabledForCurrentLaunch { + SentrySDK.capture(message: "Scroll lag detected") { scope in + scope.setLevel(.warning) + scope.setContext(value: [ + "samples": samples, + "avg_ms": String(format: "%.2f", avgLag), + "max_ms": String(format: "%.2f", maxLag), + "threshold_ms": threshold + ], key: "scroll_lag") + } } + lastScrollLagReportUptime = nowUptime } // Reset stats scrollLagSampleCount = 0 @@ -295,7 +853,7 @@ class GhosttyApp { // Load default config (includes user config). If this fails hard (e.g. due to // invalid user config), ghostty_app_new may return nil; we fall back below. loadDefaultConfigFilesWithLegacyFallback(primaryConfig) - updateDefaultBackground(from: primaryConfig) + updateDefaultBackground(from: primaryConfig, source: "initialize.primaryConfig") // Create runtime config with callbacks var runtimeConfig = ghostty_runtime_config_s() @@ -311,21 +869,26 @@ class GhosttyApp { } runtimeConfig.read_clipboard_cb = { userdata, location, state in // Read clipboard - guard let userdata else { return } - let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue() - guard let surface = surfaceView.terminalSurface?.surface else { return } + guard let callbackContext = GhosttyApp.callbackContext(from: userdata), + let surface = callbackContext.runtimeSurface else { return } let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) - let value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? "" + var value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? "" + + // When clipboard has only image data (e.g. screenshot), save as temp + // PNG and paste the file path so CLI tools can receive images. + if value.isEmpty, let imagePath = GhosttyPasteboardHelper.saveClipboardImageIfNeeded() { + value = imagePath + } value.withCString { ptr in ghostty_surface_complete_clipboard_request(surface, ptr, state, false) } } runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in - guard let userdata, let content else { return } - let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue() - guard let surface = surfaceView.terminalSurface?.surface else { return } + guard let content else { return } + guard let callbackContext = GhosttyApp.callbackContext(from: userdata), + let surface = callbackContext.runtimeSurface else { return } ghostty_surface_complete_clipboard_request(surface, content, state, true) } @@ -357,17 +920,16 @@ class GhosttyApp { } } runtimeConfig.close_surface_cb = { userdata, needsConfirmClose in - guard let userdata else { return } - let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue() - let callbackSurfaceId = surfaceView.terminalSurface?.id - let callbackTabId = surfaceView.tabId + guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return } + let callbackSurfaceId = callbackContext.surfaceId + let callbackTabId = callbackContext.tabId #if DEBUG cmuxWriteChildExitProbe( [ "probeCloseSurfaceNeedsConfirm": needsConfirmClose ? "1" : "0", "probeCloseSurfaceTabId": callbackTabId?.uuidString ?? "", - "probeCloseSurfaceSurfaceId": callbackSurfaceId?.uuidString ?? "", + "probeCloseSurfaceSurfaceId": callbackSurfaceId.uuidString, ], increments: ["probeCloseSurfaceCbCount": 1] ) @@ -378,7 +940,6 @@ class GhosttyApp { // Close requests must be resolved by the callback's workspace/surface IDs only. // If the mapping is already gone (duplicate/stale callback), ignore it. if let callbackTabId, - let callbackSurfaceId, let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), workspace.panels[callbackSurfaceId] != nil { @@ -417,7 +978,7 @@ class GhosttyApp { } ghostty_config_finalize(fallbackConfig) - updateDefaultBackground(from: fallbackConfig) + updateDefaultBackground(from: fallbackConfig, source: "initialize.fallbackConfig") guard let created = ghostty_app_new(&runtimeConfig, fallbackConfig) else { #if DEBUG @@ -434,6 +995,7 @@ class GhosttyApp { } // Notify observers that a usable config is available (initial load). + lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) #if os(macOS) @@ -464,10 +1026,200 @@ class GhosttyApp { private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { ghostty_config_load_default_files(config) + loadReleaseAppSupportGhosttyConfigIfNeeded(config) loadLegacyGhosttyConfigIfNeeded(config) + ghostty_config_load_recursive_files(config) + loadCJKFontFallbackIfNeeded(config) ghostty_config_finalize(config) } + /// When the user has not configured `font-codepoint-map` for CJK ranges, + /// Ghostty's `CTFontCollection` scoring may pick an inappropriate fallback + /// font for Hiragana, Katakana, and CJK symbols. The scoring prioritizes + /// monospace fonts, so decorative fonts with monospace attributes (e.g. + /// AB_appare from Adobe CC, or LingWai) can be selected depending on what + /// is installed. This injects a sensible default based on the system's + /// preferred languages. + /// + /// See: https://github.com/manaflow-ai/cmux/pull/1017 + private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) { + if Self.userConfigContainsCJKCodepointMap() { return } + + guard let mappings = Self.cjkFontMappings() else { return } + + let lines = mappings.map { range, font in + "font-codepoint-map = \(range)=\(font)" + }.joined(separator: "\n") + + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf") + do { + try lines.write(to: tmpURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmpURL) } + tmpURL.path.withCString { path in + ghostty_config_load_file(config, path) + } + } catch { + #if DEBUG + Self.initLog("failed to write CJK font fallback config: \(error)") + #endif + } + } + + /// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms). + private static let sharedCJKRanges = [ + "U+3000-U+303F", // CJK Symbols and Punctuation + "U+4E00-U+9FFF", // CJK Unified Ideographs + "U+F900-U+FAFF", // CJK Compatibility Ideographs + "U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms + "U+3400-U+4DBF", // CJK Unified Ideographs Extension A + ] + + /// Unicode ranges specific to Japanese (kana). + private static let japaneseRanges = [ + "U+3040-U+309F", // Hiragana + "U+30A0-U+30FF", // Katakana + ] + + /// Unicode ranges specific to Korean (Hangul). + private static let koreanRanges = [ + "U+AC00-U+D7AF", // Hangul Syllables + "U+1100-U+11FF", // Hangul Jamo + ] + + /// Returns (range, font) pairs for CJK font fallback based on the system's + /// preferred languages, or nil if no CJK language is detected. Each language + /// only maps its own script ranges to avoid assigning glyphs to a font that + /// lacks coverage (e.g. Hangul to Hiragino Sans). + static func cjkFontMappings( + preferredLanguages: [String] = Locale.preferredLanguages + ) -> [(String, String)]? { + var mappings: [(String, String)] = [] + var coveredShared = false + + for lang in preferredLanguages { + let lower = lang.lowercased() + let font: String + var langRanges: [String] = [] + + if lower.hasPrefix("ja") { + font = "Hiragino Sans" + langRanges = japaneseRanges + } else if lower.hasPrefix("ko") { + font = "Apple SD Gothic Neo" + langRanges = koreanRanges + } else if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") { + font = "PingFang TC" + } else if lower.hasPrefix("zh") { + font = "PingFang SC" + } else { + continue + } + + if !coveredShared { + for range in sharedCJKRanges { + mappings.append((range, font)) + } + coveredShared = true + } + + for range in langRanges { + mappings.append((range, font)) + } + } + + return mappings.isEmpty ? nil : mappings + } + + /// Checks whether the user's Ghostty config files already contain + /// a `font-codepoint-map` entry covering CJK ranges. Also checks + /// application-support config paths that cmux may load at runtime. + static func userConfigContainsCJKCodepointMap( + configPaths: [String] = defaultCJKScanPaths() + ) -> Bool { + var visited = Set<String>() + for rawPath in configPaths { + let path = NSString(string: rawPath).expandingTildeInPath + if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) { + return true + } + } + return false + } + + /// Returns the default set of config paths to scan for existing + /// `font-codepoint-map` entries. Includes both the standard Ghostty + /// config locations and any app-support paths that cmux may load. + private static func defaultCJKScanPaths() -> [String] { + var paths = [ + "~/.config/ghostty/config", + "~/.config/ghostty/config.ghostty", + "~/Library/Application Support/com.mitchellh.ghostty/config", + "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", + ] + if let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first { + let releaseDir = appSupport.appendingPathComponent(releaseBundleIdentifier) + paths.append(releaseDir.appendingPathComponent("config").path) + paths.append(releaseDir.appendingPathComponent("config.ghostty").path) + + if let bundleId = Bundle.main.bundleIdentifier, bundleId != releaseBundleIdentifier { + let currentDir = appSupport.appendingPathComponent(bundleId) + paths.append(currentDir.appendingPathComponent("config").path) + paths.append(currentDir.appendingPathComponent("config.ghostty").path) + } + } + return paths + } + + /// Scans a single config file (and any files it includes) for + /// `font-codepoint-map` entries. Tracks visited paths to prevent + /// infinite recursion on cyclic includes. + private static func configFileContainsCodepointMap( + atPath path: String, + visited: inout Set<String> + ) -> Bool { + let resolved = (path as NSString).standardizingPath + guard !visited.contains(resolved) else { return false } + visited.insert(resolved) + + guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else { + return false + } + let parentDir = (resolved as NSString).deletingLastPathComponent + + for line in contents.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("#") { continue } + if trimmed.hasPrefix("font-codepoint-map") { + return true + } + if trimmed.hasPrefix("config-file") { + let parts = trimmed.split(separator: "=", maxSplits: 1) + if parts.count == 2 { + var includePath = parts[1] + .trimmingCharacters(in: .whitespaces) + // Ghostty supports optional includes with a trailing '?' + if includePath.hasSuffix("?") { + includePath.removeLast() + } + includePath = includePath + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + let expanded = NSString(string: includePath).expandingTildeInPath + let absolute = (expanded as NSString).isAbsolutePath + ? expanded + : (parentDir as NSString).appendingPathComponent(expanded) + if configFileContainsCodepointMap(atPath: absolute, visited: &visited) { + return true + } + } + } + } + return false + } + static func shouldLoadLegacyGhosttyConfig( newConfigFileSize: Int?, legacyConfigFileSize: Int? @@ -477,6 +1229,110 @@ class GhosttyApp { return true } + static func shouldLoadReleaseAppSupportGhosttyConfig( + currentBundleIdentifier: String?, + currentConfigFileSize: Int?, + currentLegacyConfigFileSize: Int?, + releaseConfigFileSize: Int?, + releaseLegacyConfigFileSize: Int? + ) -> Bool { + guard SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) else { return false } + + let hasCurrentAppSupportConfig = (currentConfigFileSize ?? 0) > 0 || (currentLegacyConfigFileSize ?? 0) > 0 + guard !hasCurrentAppSupportConfig else { return false } + + let hasReleaseAppSupportConfig = (releaseConfigFileSize ?? 0) > 0 || (releaseLegacyConfigFileSize ?? 0) > 0 + return hasReleaseAppSupportConfig + } + + static func shouldApplyDefaultBackgroundUpdate( + currentScope: GhosttyDefaultBackgroundUpdateScope, + incomingScope: GhosttyDefaultBackgroundUpdateScope + ) -> Bool { + incomingScope.rawValue >= currentScope.rawValue + } + + static func shouldReloadConfigurationForAppearanceChange( + previousColorScheme: GhosttyConfig.ColorSchemePreference?, + currentColorScheme: GhosttyConfig.ColorSchemePreference + ) -> Bool { + previousColorScheme != currentColorScheme + } + + static func shouldCaptureScrollLagEvent( + samples: Int, + averageMs: Double, + maxMs: Double, + thresholdMs: Double, + minimumSamples: Int = 8, + minimumAverageMs: Double = 12, + nowUptime: TimeInterval, + lastReportedUptime: TimeInterval?, + cooldown: TimeInterval = 300 + ) -> Bool { + guard samples >= minimumSamples else { return false } + guard averageMs.isFinite, maxMs.isFinite, thresholdMs.isFinite, nowUptime.isFinite, cooldown.isFinite else { + return false + } + guard averageMs >= minimumAverageMs else { return false } + guard maxMs > thresholdMs else { return false } + if let lastReportedUptime, nowUptime - lastReportedUptime < cooldown { + return false + } + return true + } + + private func loadReleaseAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) { + #if os(macOS) + let fm = FileManager.default + guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } + guard let currentBundleIdentifier = Bundle.main.bundleIdentifier, + !currentBundleIdentifier.isEmpty else { return } + + let currentAppSupportDir = appSupport.appendingPathComponent(currentBundleIdentifier, isDirectory: true) + let releaseAppSupportDir = appSupport.appendingPathComponent(Self.releaseBundleIdentifier, isDirectory: true) + let currentConfig = currentAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false) + let currentLegacyConfig = currentAppSupportDir.appendingPathComponent("config", isDirectory: false) + let releaseConfig = releaseAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false) + let releaseLegacyConfig = releaseAppSupportDir.appendingPathComponent("config", isDirectory: false) + + func fileSize(_ url: URL) -> Int? { + guard let attrs = try? fm.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? NSNumber else { return nil } + return size.intValue + } + + let releaseConfigSize = fileSize(releaseConfig) + let releaseLegacyConfigSize = fileSize(releaseLegacyConfig) + + guard Self.shouldLoadReleaseAppSupportGhosttyConfig( + currentBundleIdentifier: currentBundleIdentifier, + currentConfigFileSize: fileSize(currentConfig), + currentLegacyConfigFileSize: fileSize(currentLegacyConfig), + releaseConfigFileSize: releaseConfigSize, + releaseLegacyConfigFileSize: releaseLegacyConfigSize + ) else { return } + + if let releaseLegacyConfigSize, releaseLegacyConfigSize > 0 { + releaseLegacyConfig.path.withCString { path in + ghostty_config_load_file(config, path) + } + } + + if let releaseConfigSize, releaseConfigSize > 0 { + releaseConfig.path.withCString { path in + ghostty_config_load_file(config, path) + } + } + + #if DEBUG + Self.initLog( + "loaded release app support ghostty config fallback from: \(releaseAppSupportDir.path)" + ) + #endif + #endif + } + private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real @@ -524,18 +1380,33 @@ class GhosttyApp { } } - func reloadConfiguration(soft: Bool = false) { - guard let app else { return } + func reloadConfiguration(soft: Bool = false, source: String = "unspecified") { + guard let app else { + logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app") + return + } + logThemeAction("reload begin source=\(source) soft=\(soft)") + resetDefaultBackgroundUpdateScope(source: "reloadConfiguration(source=\(source))") if soft, let config { ghostty_app_update_config(app, config) + lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) + scheduleSurfaceRefreshAfterConfigurationReload(source: source) + logThemeAction("reload end source=\(source) soft=\(soft) mode=soft") return } - guard let newConfig = ghostty_config_new() else { return } + guard let newConfig = ghostty_config_new() else { + logThemeAction("reload skipped source=\(source) soft=\(soft) reason=config_alloc_failed") + return + } loadDefaultConfigFilesWithLegacyFallback(newConfig) ghostty_app_update_config(app, newConfig) - updateDefaultBackground(from: newConfig) + updateDefaultBackground( + from: newConfig, + source: "reloadConfiguration(source=\(source))", + scope: .unscoped + ) DispatchQueue.main.async { self.applyBackgroundToKeyWindow() } @@ -543,19 +1414,44 @@ class GhosttyApp { ghostty_config_free(oldConfig) } config = newConfig + lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) + scheduleSurfaceRefreshAfterConfigurationReload(source: source) + logThemeAction("reload end source=\(source) soft=\(soft) mode=full") } - func reloadConfiguration(for surface: ghostty_surface_t, soft: Bool = false) { - if soft, let config { - ghostty_surface_update_config(surface, config) - return + private func scheduleSurfaceRefreshAfterConfigurationReload(source: String) { + DispatchQueue.main.async { + AppDelegate.shared?.refreshTerminalSurfacesAfterGhosttyConfigReload(source: source) } + } - guard let newConfig = ghostty_config_new() else { return } - loadDefaultConfigFilesWithLegacyFallback(newConfig) - ghostty_surface_update_config(surface, newConfig) - ghostty_config_free(newConfig) + func synchronizeThemeWithAppearance(_ appearance: NSAppearance?, source: String) { + let currentColorScheme = GhosttyConfig.currentColorSchemePreference( + appAppearance: appearance ?? NSApp?.effectiveAppearance + ) + let shouldReload = Self.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: lastAppearanceColorScheme, + currentColorScheme: currentColorScheme + ) + if backgroundLogEnabled { + let previousLabel: String + switch lastAppearanceColorScheme { + case .light: + previousLabel = "light" + case .dark: + previousLabel = "dark" + case nil: + previousLabel = "nil" + } + let currentLabel: String = currentColorScheme == .dark ? "dark" : "light" + logBackground( + "appearance sync source=\(source) previous=\(previousLabel) current=\(currentLabel) reload=\(shouldReload)" + ) + } + guard shouldReload else { return } + lastAppearanceColorScheme = currentColorScheme + reloadConfiguration(source: "appearanceSync:\(source)") } func openConfigurationInTextEdit() { @@ -577,15 +1473,30 @@ class GhosttyApp { return String(decoding: buffer, as: UTF8.self) } - private func updateDefaultBackground(from config: ghostty_config_t?) { - guard let config else { return } - let previousHex = defaultBackgroundColor.hexString() - let previousOpacity = defaultBackgroundOpacity + private func resetDefaultBackgroundUpdateScope(source: String) { + let previousScope = defaultBackgroundUpdateScope + let previousScopeSource = defaultBackgroundScopeSource + defaultBackgroundUpdateScope = .unscoped + defaultBackgroundScopeSource = "reset:\(source)" + if backgroundLogEnabled { + logBackground( + "default background scope reset source=\(source) previousScope=\(previousScope.logLabel) previousSource=\(previousScopeSource)" + ) + } + } + private func updateDefaultBackground( + from config: ghostty_config_t?, + source: String, + scope: GhosttyDefaultBackgroundUpdateScope = .unscoped + ) { + guard let config else { return } + + var resolvedColor = defaultBackgroundColor var color = ghostty_config_color_s() let bgKey = "background" if ghostty_config_get(config, &color, bgKey, UInt(bgKey.lengthOfBytes(using: .utf8))) { - defaultBackgroundColor = NSColor( + resolvedColor = NSColor( red: CGFloat(color.r) / 255, green: CGFloat(color.g) / 255, blue: CGFloat(color.b) / 255, @@ -593,39 +1504,181 @@ class GhosttyApp { ) } - var opacity: Double = 1.0 + var opacity = defaultBackgroundOpacity let opacityKey = "background-opacity" _ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8))) + opacity = min(1.0, max(0.0, opacity)) + applyDefaultBackground( + color: resolvedColor, + opacity: opacity, + source: source, + scope: scope + ) + } + + func focusFollowsMouseEnabled() -> Bool { + guard let config else { return false } + var enabled = false + let key = "focus-follows-mouse" + let keyLength = UInt(key.lengthOfBytes(using: .utf8)) + let found = ghostty_config_get(config, &enabled, key, keyLength) + return found && enabled + } + + func appleScriptAutomationEnabled() -> Bool { + guard let config else { return false } + var enabled = false + let key = "macos-applescript" + _ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8))) + return enabled + } + + fileprivate func shellIntegrationMode() -> String { + guard let config else { return "detect" } + var value: UnsafePointer<Int8>? + let key = "shell-integration" + guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), + let value else { + return "detect" + } + return String(cString: value) + } + + private func bellFeatures() -> CUnsignedInt { + guard let config else { return 0 } + var features: CUnsignedInt = 0 + let key = "bell-features" + _ = ghostty_config_get(config, &features, key, UInt(key.lengthOfBytes(using: .utf8))) + return features + } + + private func bellAudioPath() -> String? { + guard let config else { return nil } + var value = ghostty_config_path_s() + let key = "bell-audio-path" + guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), + let rawPath = value.path else { + return nil + } + let path = String(cString: rawPath) + return path.isEmpty ? nil : path + } + + private func bellAudioVolume() -> Float { + guard let config else { return 0.5 } + var value: Double = 0.5 + let key = "bell-audio-volume" + _ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) + return Float(min(1.0, max(0.0, value))) + } + + private func ringBell() { + let features = bellFeatures() + + if (features & (1 << 0)) != 0 { + NSSound.beep() + } + + if (features & (1 << 1)) != 0, + let path = bellAudioPath(), + let sound = NSSound(contentsOfFile: path, byReference: false) { + sound.volume = bellAudioVolume() + bellAudioSound = sound + if !sound.play() { + bellAudioSound = nil + } + } + + if (features & (1 << 2)) != 0 { + NSApp.requestUserAttention(.informationalRequest) + } + } + + private func applyDefaultBackground( + color: NSColor, + opacity: Double, + source: String, + scope: GhosttyDefaultBackgroundUpdateScope + ) { + let previousScope = defaultBackgroundUpdateScope + let previousScopeSource = defaultBackgroundScopeSource + guard Self.shouldApplyDefaultBackgroundUpdate(currentScope: previousScope, incomingScope: scope) else { + if backgroundLogEnabled { + logBackground( + "default background skipped source=\(source) incomingScope=\(scope.logLabel) currentScope=\(previousScope.logLabel) currentSource=\(previousScopeSource) color=\(color.hexString()) opacity=\(String(format: "%.3f", opacity))" + ) + } + return + } + + defaultBackgroundUpdateScope = scope + defaultBackgroundScopeSource = source + + let previousHex = defaultBackgroundColor.hexString() + let previousOpacity = defaultBackgroundOpacity + defaultBackgroundColor = color defaultBackgroundOpacity = opacity let hasChanged = previousHex != defaultBackgroundColor.hexString() || abs(previousOpacity - defaultBackgroundOpacity) > 0.0001 if hasChanged { - notifyDefaultBackgroundDidChange() + notifyDefaultBackgroundDidChange(source: source) } if backgroundLogEnabled { - logBackground("default background updated color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity))") + logBackground( + "default background updated source=\(source) scope=\(scope.logLabel) previousScope=\(previousScope.logLabel) previousScopeSource=\(previousScopeSource) previousColor=\(previousHex) previousOpacity=\(String(format: "%.3f", previousOpacity)) color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity)) changed=\(hasChanged)" + ) } } - private func notifyDefaultBackgroundDidChange() { - let userInfo: [AnyHashable: Any] = [ - GhosttyNotificationKey.backgroundColor: defaultBackgroundColor, - GhosttyNotificationKey.backgroundOpacity: defaultBackgroundOpacity - ] - let post = { - NotificationCenter.default.post( - name: .ghosttyDefaultBackgroundDidChange, - object: nil, - userInfo: userInfo + private func nextBackgroundEventId() -> UInt64 { + precondition(Thread.isMainThread, "Background event IDs must be generated on main thread") + backgroundEventCounter &+= 1 + return backgroundEventCounter + } + + private func notifyDefaultBackgroundDidChange(source: String) { + let signal = { [self] in + let eventId = nextBackgroundEventId() + defaultBackgroundNotificationDispatcher.signal( + backgroundColor: defaultBackgroundColor, + opacity: defaultBackgroundOpacity, + eventId: eventId, + source: source ) } if Thread.isMainThread { - post() + signal() } else { - DispatchQueue.main.async(execute: post) + DispatchQueue.main.async(execute: signal) } } + private func logThemeAction(_ message: String) { + guard backgroundLogEnabled else { return } + logBackground("theme action \(message)") + } + + private func actionLabel(for action: ghostty_action_s) -> String { + switch action.tag { + case GHOSTTY_ACTION_RELOAD_CONFIG: + return "reload_config" + case GHOSTTY_ACTION_CONFIG_CHANGE: + return "config_change" + case GHOSTTY_ACTION_COLOR_CHANGE: + return "color_change" + default: + return String(describing: action.tag) + } + } + + private func logAction(_ action: ghostty_action_s, target: ghostty_target_s, tabId: UUID?, surfaceId: UUID?) { + guard backgroundLogEnabled else { return } + let targetLabel = target.tag == GHOSTTY_TARGET_SURFACE ? "surface" : "app" + logBackground( + "action event target=\(targetLabel) action=\(actionLabel(for: action)) tab=\(tabId?.uuidString ?? "nil") surface=\(surfaceId?.uuidString ?? "nil")" + ) + } + private func performOnMain<T>(_ work: @MainActor () -> T) -> T { if Thread.isMainThread { return MainActor.assumeIsolated { work() } @@ -669,8 +1722,19 @@ class GhosttyApp { } } + private static func callbackContext(from userdata: UnsafeMutableRawPointer?) -> GhosttySurfaceCallbackContext? { + guard let userdata else { return nil } + return Unmanaged<GhosttySurfaceCallbackContext>.fromOpaque(userdata).takeUnretainedValue() + } + private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { + if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || + action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || + action.tag == GHOSTTY_ACTION_COLOR_CHANGE { + logAction(action, target: target, tabId: nil, surfaceId: nil) + } + if action.tag == GHOSTTY_ACTION_DESKTOP_NOTIFICATION { let actionTitle = action.action.desktop_notification.title .flatMap { String(cString: $0) } ?? "" @@ -696,10 +1760,18 @@ class GhosttyApp { } } + if action.tag == GHOSTTY_ACTION_RING_BELL { + performOnMain { + self.ringBell() + } + return true + } + if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG { let soft = action.action.reload_config.soft + logThemeAction("reload request target=app soft=\(soft)") performOnMain { - GhosttyApp.shared.reloadConfiguration(soft: soft) + GhosttyApp.shared.reloadConfiguration(soft: soft, source: "action.reload_config.app") } return true } @@ -707,16 +1779,18 @@ class GhosttyApp { if action.tag == GHOSTTY_ACTION_COLOR_CHANGE, action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change - defaultBackgroundColor = NSColor( + let resolvedColor = NSColor( red: CGFloat(change.r) / 255, green: CGFloat(change.g) / 255, blue: CGFloat(change.b) / 255, alpha: 1.0 ) - if backgroundLogEnabled { - logBackground("OSC background change (app target) color=\(defaultBackgroundColor)") - } - notifyDefaultBackgroundDidChange() + applyDefaultBackground( + color: resolvedColor, + opacity: defaultBackgroundOpacity, + source: "action.color_change.app", + scope: .app + ) DispatchQueue.main.async { GhosttyApp.shared.applyBackgroundToKeyWindow() } @@ -724,7 +1798,11 @@ class GhosttyApp { } if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE { - updateDefaultBackground(from: action.action.config_change.config) + updateDefaultBackground( + from: action.action.config_change.config, + source: "action.config_change.app", + scope: .app + ) DispatchQueue.main.async { GhosttyApp.shared.applyBackgroundToKeyWindow() } @@ -733,8 +1811,57 @@ class GhosttyApp { return false } - guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false } - let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue() + let callbackContext = Self.callbackContext(from: ghostty_surface_userdata(target.target.surface)) + let callbackTabId = callbackContext?.tabId + let callbackSurfaceId = callbackContext?.surfaceId + + if action.tag == GHOSTTY_ACTION_SHOW_CHILD_EXITED { + // The child (shell) exited. Ghostty will fall back to printing + // "Process exited. Press any key..." into the terminal unless the host + // handles this action. For cmux, the correct behavior is to close + // the panel immediately (no prompt). +#if DEBUG + dlog( + "surface.action.showChildExited tab=\(callbackTabId?.uuidString.prefix(5) ?? "nil") " + + "surface=\(callbackSurfaceId?.uuidString.prefix(5) ?? "nil")" + ) +#endif +#if DEBUG + cmuxWriteChildExitProbe( + [ + "probeShowChildExitedTabId": callbackTabId?.uuidString ?? "", + "probeShowChildExitedSurfaceId": callbackSurfaceId?.uuidString ?? "", + ], + increments: ["probeShowChildExitedCount": 1] + ) +#endif + // Keep host-close async to avoid re-entrant close/deinit while Ghostty is still + // dispatching this action callback. + DispatchQueue.main.async { + guard let app = AppDelegate.shared else { return } + if let callbackTabId, + let callbackSurfaceId, + let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, + let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), + workspace.panels[callbackSurfaceId] != nil { + manager.closePanelAfterChildExited(tabId: callbackTabId, surfaceId: callbackSurfaceId) + } + } + // Always report handled so Ghostty doesn't print the fallback prompt. + return true + } + + guard let surfaceView = callbackContext?.surfaceView else { return false } + if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || + action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || + action.tag == GHOSTTY_ACTION_COLOR_CHANGE { + logAction( + action, + target: target, + tabId: callbackTabId ?? surfaceView.tabId, + surfaceId: callbackSurfaceId ?? surfaceView.terminalSurface?.id + ) + } switch action.tag { case GHOSTTY_ACTION_NEW_SPLIT: @@ -747,6 +1874,11 @@ class GhosttyApp { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } + case GHOSTTY_ACTION_RING_BELL: + performOnMain { + self.ringBell() + } + return true case GHOSTTY_ACTION_GOTO_SPLIT: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, @@ -897,34 +2029,6 @@ class GhosttyApp { ) } return true - case GHOSTTY_ACTION_SHOW_CHILD_EXITED: - // The child (shell) exited. Ghostty will fall back to printing - // "Process exited. Press any key..." into the terminal unless the host - // handles this action. For cmux, the correct behavior is to close - // the panel immediately (no prompt). -#if DEBUG - cmuxWriteChildExitProbe( - [ - "probeShowChildExitedTabId": surfaceView.tabId?.uuidString ?? "", - "probeShowChildExitedSurfaceId": surfaceView.terminalSurface?.id.uuidString ?? "", - ], - increments: ["probeShowChildExitedCount": 1] - ) -#endif - // Keep host-close async to avoid re-entrant close/deinit while Ghostty is still - // dispatching this action callback. - DispatchQueue.main.async { - guard let app = AppDelegate.shared else { return } - if let tabId = surfaceView.tabId, - let surfaceId = surfaceView.terminalSurface?.id, - let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, - let workspace = manager.tabs.first(where: { $0.id == tabId }), - workspace.panels[surfaceId] != nil { - manager.closePanelAfterChildExited(tabId: tabId, surfaceId: surfaceId) - } - } - // Always report handled so Ghostty doesn't print the fallback prompt. - return true case GHOSTTY_ACTION_COLOR_CHANGE: if action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change @@ -934,6 +2038,11 @@ class GhosttyApp { blue: CGFloat(change.b) / 255, alpha: 1.0 ) + if backgroundLogEnabled { + logBackground( + "surface override set tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString()) source=action.color_change.surface" + ) + } surfaceView.applySurfaceBackground() if backgroundLogEnabled { logBackground("OSC background change tab=\(surfaceView.tabId?.uuidString ?? "unknown") color=\(surfaceView.backgroundColor?.description ?? "nil")") @@ -944,19 +2053,40 @@ class GhosttyApp { } return true case GHOSTTY_ACTION_CONFIG_CHANGE: - updateDefaultBackground(from: action.action.config_change.config) - DispatchQueue.main.async { - surfaceView.applyWindowBackgroundIfActive() + if let staleOverride = surfaceView.backgroundColor { + surfaceView.backgroundColor = nil + if backgroundLogEnabled { + logBackground( + "surface override cleared tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") cleared=\(staleOverride.hexString()) source=action.config_change.surface" + ) + } + surfaceView.applySurfaceBackground() + DispatchQueue.main.async { + surfaceView.applyWindowBackgroundIfActive() + } + } + updateDefaultBackground( + from: action.action.config_change.config, + source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", + scope: .surface + ) + if backgroundLogEnabled { + logBackground( + "surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString())" + ) } return true case GHOSTTY_ACTION_RELOAD_CONFIG: let soft = action.action.reload_config.soft + logThemeAction( + "reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)" + ) return performOnMain { - if let surface = surfaceView.terminalSurface?.surface { - GhosttyApp.shared.reloadConfiguration(for: surface, soft: soft) - } else { - GhosttyApp.shared.reloadConfiguration(soft: soft) - } + // Keep all runtime theme/default-background state in the same path. + GhosttyApp.shared.reloadConfiguration( + soft: soft, + source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")" + ) return true } case GHOSTTY_ACTION_KEY_SEQUENCE: @@ -972,20 +2102,48 @@ class GhosttyApp { case GHOSTTY_ACTION_OPEN_URL: let openUrl = action.action.open_url guard let cstr = openUrl.url else { return false } - let urlString = String(cString: cstr) - guard let target = resolveTerminalOpenURLTarget(urlString) else { return false } + let urlString = String( + data: Data(bytes: cstr, count: Int(openUrl.len)), + encoding: .utf8 + ) ?? "" + #if DEBUG + dlog("link.openURL raw=\(urlString)") + #endif + guard let target = resolveTerminalOpenURLTarget(urlString) else { + #if DEBUG + dlog("link.openURL resolve failed, returning false") + #endif + return false + } if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() { + #if DEBUG + dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") + #endif return performOnMain { NSWorkspace.shared.open(target.url) } } switch target { case let .external(url): + #if DEBUG + dlog("link.openURL target=external, opening externally url=\(url)") + #endif return performOnMain { NSWorkspace.shared.open(url) } case let .embeddedBrowser(url): + if BrowserLinkOpenSettings.shouldOpenExternally(url) { + #if DEBUG + dlog("link.openURL target=embedded but shouldOpenExternally=true url=\(url)") + #endif + return performOnMain { + NSWorkspace.shared.open(url) + } + } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { + #if DEBUG + dlog("link.openURL target=embedded but normalizeHost=nil host=\(url.host ?? "nil") url=\(url)") + #endif return performOnMain { NSWorkspace.shared.open(url) } @@ -993,22 +2151,61 @@ class GhosttyApp { // If a host whitelist is configured and this host isn't in it, open externally. if !BrowserLinkOpenSettings.hostMatchesWhitelist(host) { + #if DEBUG + dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)") + #endif return performOnMain { NSWorkspace.shared.open(url) } } - guard let tabId = surfaceView.tabId, - let surfaceId = surfaceView.terminalSurface?.id else { return false } + let sourceWorkspaceId = callbackTabId ?? surfaceView.tabId + let sourcePanelId = callbackSurfaceId ?? surfaceView.terminalSurface?.id + guard let sourceWorkspaceId, + let sourcePanelId else { + #if DEBUG + dlog("link.openURL target=embedded but tabId/surfaceId=nil") + #endif + return false + } + #if DEBUG + dlog( + "link.openURL target=embedded, opening in browser pane " + + "host=\(host) url=\(url) tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)" + ) + #endif return performOnMain { guard let app = AppDelegate.shared, - let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, - let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + let resolved = app.workspaceContainingPanel( + panelId: sourcePanelId, + preferredWorkspaceId: sourceWorkspaceId + ) else { + #if DEBUG + dlog( + "link.openURL embedded but workspace lookup failed " + + "tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)" + ) + #endif return false } - if let targetPane = workspace.preferredBrowserTargetPane(fromPanelId: surfaceId) { + let workspace = resolved.workspace + #if DEBUG + if workspace.id != sourceWorkspaceId { + dlog( + "link.openURL workspace.remap sourceTab=\(sourceWorkspaceId) " + + "resolvedTab=\(workspace.id) surfaceId=\(sourcePanelId)" + ) + } + #endif + if let targetPane = workspace.preferredBrowserTargetPane(fromPanelId: sourcePanelId) { + #if DEBUG + dlog("link.openURL opening in existing browser pane=\(targetPane)") + #endif return workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil } else { - return workspace.newBrowserSplit(from: surfaceId, orientation: .horizontal, url: url) != nil + #if DEBUG + dlog("link.openURL opening as new browser split from surface=\(sourcePanelId)") + #endif + return workspace.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, url: url) != nil } } } @@ -1019,11 +2216,11 @@ class GhosttyApp { private func applyBackgroundToKeyWindow() { guard let window = activeMainWindow() else { return } - if cmuxShouldUseTransparentBackgroundWindow() { - window.backgroundColor = .clear + if cmuxShouldUseClearWindowBackground(for: defaultBackgroundOpacity) { + window.backgroundColor = cmuxTransparentWindowBaseColor() window.isOpaque = false if backgroundLogEnabled { - logBackground("applied transparent window for behindWindow blur") + logBackground("applied transparent window background opacity=\(String(format: "%.3f", defaultBackgroundOpacity))") } } else { let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity) @@ -1048,7 +2245,17 @@ class GhosttyApp { } func logBackground(_ message: String) { - let line = "cmux bg: \(message)\n" + let timestamp = Self.backgroundLogTimestampFormatter.string(from: Date()) + let uptimeMs = (ProcessInfo.processInfo.systemUptime - backgroundLogStartUptime) * 1000 + let frame60 = Int((CACurrentMediaTime() * 60.0).rounded(.down)) + let frame120 = Int((CACurrentMediaTime() * 120.0).rounded(.down)) + let threadLabel = Thread.isMainThread ? "main" : "background" + backgroundLogLock.lock() + defer { backgroundLogLock.unlock() } + backgroundLogSequence &+= 1 + let sequence = backgroundLogSequence + let line = + "\(timestamp) seq=\(sequence) t+\(String(format: "%.3f", uptimeMs))ms thread=\(threadLabel) frame60=\(frame60) frame120=\(frame120) cmux bg: \(message)\n" if let data = line.data(using: .utf8) { if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false { FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil) @@ -1126,6 +2333,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? + private let additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 @@ -1136,11 +2344,27 @@ final class TerminalSurface: Identifiable, ObservableObject { private var pendingTextBytes: Int = 0 private let maxPendingTextBytes = 1_048_576 private var backgroundSurfaceStartQueued = false + private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>? + private enum PortalLifecycleState: String { + case live + case closing + case closed + } + private struct PortalHostLease { + let hostId: ObjectIdentifier + let inWindow: Bool + let area: CGFloat + } + private var portalLifecycleState: PortalLifecycleState = .live + private var portalLifecycleGeneration: UInt64 = 1 + private var activePortalHostLease: PortalHostLease? @Published var searchState: SearchState? = nil { didSet { if let searchState { hostedView.cancelFocusRequest() - NSLog("Find: search state created tab=%@ surface=%@", tabId.uuidString, id.uuidString) +#if DEBUG + dlog("find.searchState created tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))") +#endif searchNeedleCancellable = searchState.$needle .removeDuplicates() .map { needle -> AnyPublisher<String, Never> in @@ -1154,29 +2378,37 @@ final class TerminalSurface: Identifiable, ObservableObject { } .switchToLatest() .sink { [weak self] needle in - NSLog("Find: needle updated tab=%@ surface=%@ needle=%@", self?.tabId.uuidString ?? "unknown", self?.id.uuidString ?? "unknown", needle) +#if DEBUG + dlog("find.needle updated tab=\(self?.tabId.uuidString.prefix(5) ?? "?") surface=\(self?.id.uuidString.prefix(5) ?? "?") chars=\(needle.count)") +#endif _ = self?.performBindingAction("search:\(needle)") } } else if oldValue != nil { searchNeedleCancellable = nil - NSLog("Find: search state cleared tab=%@ surface=%@", tabId.uuidString, id.uuidString) +#if DEBUG + dlog("find.searchState cleared tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))") +#endif _ = performBindingAction("end_search") } } } + @Published private(set) var keyboardCopyModeActive: Bool = false private var searchNeedleCancellable: AnyCancellable? + var currentKeyStateIndicatorText: String? { surfaceView.currentKeyStateIndicatorText } init( tabId: UUID, context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?, - workingDirectory: String? = nil + workingDirectory: String? = nil, + additionalEnvironment: [String: String] = [:] ) { self.id = UUID() self.tabId = tabId self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) + self.additionalEnvironment = additionalEnvironment // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer // has non-zero bounds and the renderer can initialize without presenting a blank/stretched // intermediate frame on the first real resize. @@ -1193,10 +2425,174 @@ final class TerminalSurface: Identifiable, ObservableObject { attachedView?.tabId = newTabId surfaceView.tabId = newTabId } + + func isAttached(to view: GhosttyNSView) -> Bool { + attachedView === view && surface != nil + } + + func portalBindingGeneration() -> UInt64 { + portalLifecycleGeneration + } + + func portalBindingStateLabel() -> String { + portalLifecycleState.rawValue + } + + func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { + guard portalLifecycleState == .live else { return false } + if let expectedSurfaceId, expectedSurfaceId != id { + return false + } + if let expectedGeneration, expectedGeneration != portalLifecycleGeneration { + return false + } + return true + } + + private static let portalHostAreaThreshold: CGFloat = 4 + private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2 + + private static func portalHostArea(for bounds: CGRect) -> CGFloat { + max(0, bounds.width) * max(0, bounds.height) + } + + private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool { + lease.inWindow && lease.area > portalHostAreaThreshold + } + + func claimPortalHost( + hostId: ObjectIdentifier, + inWindow: Bool, + bounds: CGRect, + reason: String + ) -> Bool { + let next = PortalHostLease( + hostId: hostId, + inWindow: inWindow, + area: Self.portalHostArea(for: bounds) + ) + + if let current = activePortalHostLease { + if current.hostId == hostId { + activePortalHostLease = next + return true + } + + let currentUsable = Self.portalHostIsUsable(current) + let nextUsable = Self.portalHostIsUsable(next) + let shouldReplace = + !currentUsable || + (nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio)) + + if shouldReplace { +#if DEBUG + dlog( + "terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " + + "size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "replacingHost=\(current.hostId) replacingInWin=\(current.inWindow ? 1 : 0) " + + "replacingArea=\(String(format: "%.1f", current.area))" + ) +#endif + activePortalHostLease = next + return true + } + +#if DEBUG + dlog( + "terminal.portal.host.skip surface=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " + + "size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "ownerHost=\(current.hostId) ownerInWin=\(current.inWindow ? 1 : 0) " + + "ownerArea=\(String(format: "%.1f", current.area))" + ) +#endif + return false + } + + activePortalHostLease = next +#if DEBUG + dlog( + "terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " + + "size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) replacingHost=nil" + ) +#endif + return true + } + + func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) { + guard let current = activePortalHostLease, current.hostId == hostId else { return } + activePortalHostLease = nil +#if DEBUG + dlog( + "terminal.portal.host.release surface=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) inWin=\(current.inWindow ? 1 : 0) " + + "area=\(String(format: "%.1f", current.area))" + ) +#endif + } + + func beginPortalCloseLifecycle(reason: String) { + guard portalLifecycleState != .closed else { return } + guard portalLifecycleState != .closing else { return } + portalLifecycleState = .closing + portalLifecycleGeneration &+= 1 +#if DEBUG + dlog( + "surface.lifecycle.close.begin surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + + "generation=\(portalLifecycleGeneration)" + ) +#endif + } + + private func markPortalLifecycleClosed(reason: String) { + guard portalLifecycleState != .closed else { return } + portalLifecycleState = .closed + portalLifecycleGeneration &+= 1 +#if DEBUG + dlog( + "surface.lifecycle.close.sealed surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + + "generation=\(portalLifecycleGeneration)" + ) +#endif + } + + /// Explicitly free the Ghostty runtime surface. Idempotent — safe to call + /// before deinit; deinit will skip the free if already torn down. + @MainActor + func teardownSurface() { + markPortalLifecycleClosed(reason: "teardown") + + let callbackContext = surfaceCallbackContext + surfaceCallbackContext = nil + + let surfaceToFree = surface + surface = nil + + guard let surfaceToFree else { + callbackContext?.release() + return + } + + Task { @MainActor in + // Keep free behavior aligned with deinit: perform the runtime teardown on + // the next main-actor turn so SIGHUP delivery is deterministic but non-reentrant. + ghostty_surface_free(surfaceToFree) + callbackContext?.release() + } + } + #if DEBUG private static let surfaceLogPath = "/tmp/cmux-ghostty-surface.log" private static let sizeLogPath = "/tmp/cmux-ghostty-size.log" + func debugCurrentPixelSize() -> (width: UInt32, height: UInt32) { + (lastPixelWidth, lastPixelHeight) + } + private static func surfaceLog(_ message: String) { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" @@ -1224,6 +2620,17 @@ final class TerminalSurface: Identifiable, ObservableObject { } #endif + /// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived + /// from backing-space points and truncated (never rounded up). + private func pixelDimension(from value: CGFloat) -> UInt32 { + guard value.isFinite else { return 0 } + let floored = floor(max(0, value)) + if floored >= CGFloat(UInt32.max) { + return UInt32.max + } + return UInt32(floored) + } + private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) { let scale = max( 1.0, @@ -1252,6 +2659,9 @@ final class TerminalSurface: Identifiable, ObservableObject { // removed/re-added (or briefly have window/screen nil) without recreating the surface. // Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing // or stale, the surface can appear visually frozen until a focus/visibility change. + // SwiftUI also re-enters this path for ordinary state propagation (drag hover, active + // markers, visibility flags), so avoid forcing a geometry refresh when the attachment + // itself is unchanged. if attachedView === view && surface != nil { #if DEBUG dlog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())") @@ -1262,7 +2672,6 @@ final class TerminalSurface: Identifiable, ObservableObject { let s = surface { ghostty_surface_set_display_id(s, displayID) } - view.forceRefreshSurface() return } @@ -1333,9 +2742,19 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) - surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque() + let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceView: view, terminalSurface: self)) + surfaceConfig.userdata = callbackContext.toOpaque() + surfaceCallbackContext?.release() + surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer surfaceConfig.context = surfaceContext +#if DEBUG + let templateFontText = String(format: "%.2f", surfaceConfig.font_size) + dlog( + "zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + + "templateFont=\(templateFontText)" + ) +#endif var envVars: [ghostty_env_var_s] = [] var envStorage: [(UnsafeMutablePointer<CChar>, UnsafeMutablePointer<CChar>)] = [] defer { @@ -1365,6 +2784,9 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_PANEL_ID"] = id.uuidString env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { + env["CMUX_BUNDLE_ID"] = bundleId + } // Port range for this workspace (base/range snapshotted once per app session) do { @@ -1403,6 +2825,9 @@ final class TerminalSurface: Identifiable, ObservableObject { ?? "/bin/zsh" let shellName = URL(fileURLWithPath: shell).lastPathComponent if shellName == "zsh" { + if GhosttyApp.shared.shellIntegrationMode() != "none" { + env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1" + } let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil) ?? getenv("ZDOTDIR").map { String(cString: $0) } ?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil) @@ -1426,6 +2851,12 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + if !additionalEnvironment.isEmpty { + for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty { + env[key] = value + } + } + if !env.isEmpty { envVars.reserveCapacity(env.count) envStorage.reserveCapacity(env.count) @@ -1459,6 +2890,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } if surface == nil { + surfaceCallbackContext?.release() + surfaceCallbackContext = nil print("Failed to create ghostty surface") #if DEBUG Self.surfaceLog("createSurface FAILED surface=\(id.uuidString): ghostty_surface_new returned nil") @@ -1476,6 +2909,7 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif return } + guard let createdSurface = surface else { return } // For vsync-driven rendering, Ghostty needs to know which display we're on so it can // start a CVDisplayLink with the right refresh rate. If we don't set this early, the @@ -1487,30 +2921,74 @@ final class TerminalSurface: Identifiable, ObservableObject { if let screen = view.window?.screen ?? NSScreen.main, let displayID = screen.displayID, displayID != 0 { - ghostty_surface_set_display_id(surface, displayID) + ghostty_surface_set_display_id(createdSurface, displayID) } - ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y) - let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero)) - let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero)) + ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) + let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size + let wpx = pixelDimension(from: backingSize.width) + let hpx = pixelDimension(from: backingSize.height) if wpx > 0, hpx > 0 { - ghostty_surface_set_size(surface, wpx, hpx) + ghostty_surface_set_size(createdSurface, wpx, hpx) lastPixelWidth = wpx lastPixelHeight = hpx lastXScale = scaleFactors.x lastYScale = scaleFactors.y } + // Some GhosttyKit builds can drop inherited font_size during post-create + // config/scale reconciliation. If runtime points don't match the inherited + // template points, re-apply via binding action so all creation paths + // (new surface, split, new workspace) preserve zoom from the source terminal. + if let inheritedFontPoints = configTemplate?.font_size, + inheritedFontPoints > 0 { + let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface) + let shouldReapply = { + guard let currentFontPoints else { return true } + return abs(currentFontPoints - inheritedFontPoints) > 0.05 + }() + if shouldReapply { + let action = String(format: "set_font_size:%.3f", inheritedFontPoints) + _ = performBindingAction(action) + } + } + flushPendingTextIfNeeded() + + // Kick an initial draw after creation/size setup. On some startup paths Ghostty can + // miss the first vsync callback and sit on a blank frame until another focus/visibility + // transition nudges the renderer. + view.forceRefreshSurface() + ghostty_surface_refresh(createdSurface) + +#if DEBUG + let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map { + String(format: "%.2f", $0) + } ?? "nil" + dlog( + "zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + + "runtimeFont=\(runtimeFontText)" + ) +#endif } - func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) { - guard let surface = surface else { return } + @discardableResult + func updateSize( + width: CGFloat, + height: CGFloat, + xScale: CGFloat, + yScale: CGFloat, + layerScale: CGFloat, + backingSize: CGSize? = nil + ) -> Bool { + guard let surface = surface else { return false } _ = layerScale - let wpx = UInt32((width * xScale).rounded(.toNearestOrAwayFromZero)) - let hpx = UInt32((height * yScale).rounded(.toNearestOrAwayFromZero)) - guard wpx > 0, hpx > 0 else { return } + let resolvedBackingWidth = backingSize?.width ?? (width * xScale) + let resolvedBackingHeight = backingSize?.height ?? (height * yScale) + let wpx = pixelDimension(from: resolvedBackingWidth) + let hpx = pixelDimension(from: resolvedBackingHeight) + guard wpx > 0, hpx > 0 else { return false } let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale) let sizeChanged = wpx != lastPixelWidth || hpx != lastPixelHeight @@ -1519,7 +2997,7 @@ final class TerminalSurface: Identifiable, ObservableObject { Self.sizeLog("updateSize-call surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) changed=\((scaleChanged || sizeChanged) ? 1 : 0)") #endif - guard scaleChanged || sizeChanged else { return } + guard scaleChanged || sizeChanged else { return false } #if DEBUG if sizeChanged { @@ -1541,39 +3019,47 @@ final class TerminalSurface: Identifiable, ObservableObject { } // Let Ghostty continue rendering on its own wakeups for steady-state frames. + return true } /// Force a full size recalculation and surface redraw. - func forceRefresh() { - let viewState: String - if let view = attachedView { - let inWindow = view.window != nil - let bounds = view.bounds - let metalOK = (view.layer as? CAMetalLayer) != nil - viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK)" - } else { - viewState = "NO_ATTACHED_VIEW" - } - #if DEBUG - let ts = ISO8601DateFormatter().string(from: Date()) - let line = "[\(ts)] forceRefresh: \(id) \(viewState)\n" - let logPath = "/tmp/cmux-refresh-debug.log" - if let handle = FileHandle(forWritingAtPath: logPath) { - handle.seekToEndOfFile() - handle.write(line.data(using: .utf8)!) - handle.closeFile() + func forceRefresh(reason: String = "unspecified") { + let hasSurface = surface != nil + let viewState: String + if let view = attachedView { + let inWindow = view.window != nil + let bounds = view.bounds + let metalOK = (view.layer as? CAMetalLayer) != nil + viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)" } else { - FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8)) + viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" } - #endif + #if DEBUG + dlog("forceRefresh: \(id) reason=\(reason) \(viewState)") + #endif guard let view = attachedView, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { return } + guard let currentSurface = self.surface else { return } + + // Re-read self.surface before each ghostty call to guard against the surface + // being freed during wake-from-sleep geometry reconciliation (issue #432). + // The surface can be invalidated between calls when AppKit layout triggers + // view lifecycle changes (e.g., forceRefreshSurface → layout → deinit → free). + + // Reassert display id on topology churn (split close/reparent) before forcing a refresh. + // This avoids a first-run stuck-vsync state where Ghostty believes vsync is active + // but callbacks have not resumed for the current display. + if let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, + displayID != 0 { + ghostty_surface_set_display_id(currentSurface, displayID) + } view.forceRefreshSurface() + guard let surface = self.surface else { return } ghostty_surface_refresh(surface) } @@ -1693,14 +3179,98 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + @discardableResult + func toggleKeyboardCopyMode() -> Bool { + let handled = surfaceView.toggleKeyboardCopyMode() + if handled { + setKeyboardCopyModeActive(surfaceView.isKeyboardCopyModeActive) + } + return handled + } + + func setKeyboardCopyModeActive(_ active: Bool) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.setKeyboardCopyModeActive(active) + } + return + } + + if keyboardCopyModeActive != active { + keyboardCopyModeActive = active + } + hostedView.syncKeyStateIndicator(text: surfaceView.currentKeyStateIndicatorText) + } + func hasSelection() -> Bool { guard let surface = surface else { return false } return ghostty_surface_has_selection(surface) } +#if DEBUG + /// Test-only helper to deterministically simulate a released runtime surface. + @MainActor + func releaseSurfaceForTesting() { + let callbackContext = surfaceCallbackContext + surfaceCallbackContext = nil + + guard let surfaceToFree = surface else { + callbackContext?.release() + return + } + + surface = nil + ghostty_surface_free(surfaceToFree) + callbackContext?.release() + } +#endif + deinit { - if let surface = surface { - ghostty_surface_free(surface) + markPortalLifecycleClosed(reason: "deinit") + + let callbackContext = surfaceCallbackContext + surfaceCallbackContext = nil + + // Nil out the surface pointer so any in-flight closures (e.g. geometry + // reconcile dispatched via DispatchQueue.main.async) that read self.surface + // before this object is fully deallocated will see nil and bail out, + // rather than passing a freed pointer to ghostty_surface_refresh (#432). + let surfaceToFree = surface + surface = nil + + guard let surfaceToFree else { +#if DEBUG + dlog( + "surface.lifecycle.deinit.skip surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=noRuntimeSurface" + ) +#endif + callbackContext?.release() + return + } + +#if DEBUG + let surfaceToken = String(id.uuidString.prefix(5)) + let workspaceToken = String(tabId.uuidString.prefix(5)) + dlog( + "surface.lifecycle.deinit.begin surface=\(surfaceToken) " + + "workspace=\(workspaceToken) hasAttachedView=\(attachedView != nil ? 1 : 0) " + + "hostedInWindow=\(hostedView.window != nil ? 1 : 0)" + ) +#endif + + // Keep teardown asynchronous to avoid re-entrant close/deinit loops, but retain + // callback userdata until surface free completes so callbacks never dereference + // a deallocated view pointer. + Task { @MainActor in + ghostty_surface_free(surfaceToFree) + callbackContext?.release() +#if DEBUG + dlog( + "surface.lifecycle.deinit.end surface=\(surfaceToken) " + + "workspace=\(workspaceToken) freed=1" + ) +#endif } } } @@ -1719,6 +3289,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { .fileURL, .URL ] + private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder") private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" fileprivate static func focusLog(_ message: String) { @@ -1737,8 +3309,31 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var onTriggerFlash: (() -> Void)? var backgroundColor: NSColor? private var appliedColorScheme: ghostty_color_scheme_e? + private var lastLoggedSurfaceBackgroundSignature: String? + private var lastLoggedWindowBackgroundSignature: String? private var keySequence: [ghostty_input_trigger_s] = [] private var keyTables: [String] = [] + fileprivate private(set) var keyboardCopyModeActive = false + private var keyboardCopyModeConsumedKeyUps: Set<UInt16> = [] + private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState() + private var keyboardCopyModeViewportRow: Int? + /// Tracks whether the user has explicitly entered visual selection mode (v). + /// Separate from Ghostty's `has_selection` because copy mode always maintains + /// a 1-cell selection as a visible cursor. This flag determines whether + /// movements should extend the selection (visual) or scroll the viewport. + private var keyboardCopyModeVisualActive = false + fileprivate var isKeyboardCopyModeActive: Bool { keyboardCopyModeActive } + fileprivate var currentKeyStateIndicatorText: String? { + if let name = keyTables.last { + return terminalKeyTableIndicatorText(name) + } + + if keyboardCopyModeActive { + return terminalKeyboardCopyModeIndicatorText + } + + return nil + } #if DEBUG private static let keyLatencyProbeEnabled: Bool = { if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" { @@ -1746,17 +3341,43 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") }() + static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)? #endif private var eventMonitor: Any? private var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? - private var lastScrollEventTime: CFTimeInterval = 0 + private var lastScrollEventTime: CFTimeInterval = 0 private var visibleInUI: Bool = true private var pendingSurfaceSize: CGSize? + private var deferredSurfaceSizeRetryQueued = false + private var lastDrawableSize: CGSize = .zero + private var isFindEscapeSuppressionArmed = false #if DEBUG private var lastSizeSkipSignature: String? #endif + private var hasUsableFocusGeometry: Bool { + bounds.width > 1 && bounds.height > 1 + } + + static func shouldRequestFirstResponderForMouseFocus( + focusFollowsMouseEnabled: Bool, + pressedMouseButtons: Int, + appIsActive: Bool, + windowIsKey: Bool, + alreadyFirstResponder: Bool, + visibleInUI: Bool, + hasUsableGeometry: Bool, + hiddenInHierarchy: Bool + ) -> Bool { + guard focusFollowsMouseEnabled else { return false } + guard pressedMouseButtons == 0 else { return false } + guard appIsActive, windowIsKey else { return false } + guard !alreadyFirstResponder else { return false } + guard visibleInUI, hasUsableGeometry, !hiddenInHierarchy else { return false } + return true + } + // Visibility is used for focus gating, not for libghostty occlusion. fileprivate var isVisibleInUI: Bool { visibleInUI } fileprivate func setVisibleInUI(_ visible: Bool) { @@ -1776,6 +3397,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func setup() { // Only enable our instrumented CAMetalLayer in targeted debug/test scenarios. // The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs. + wantsLayer = true + layer?.masksToBounds = true installEventMonitor() updateTrackingAreas() registerForDraggedTypes(Array(Self.dropTypes)) @@ -1792,29 +3415,83 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let layer { CATransaction.begin() CATransaction.setDisableActions(true) - layer.backgroundColor = color.cgColor - layer.isOpaque = color.alphaComponent >= 1.0 + // GhosttySurfaceScrollView owns the panel background fill. Keeping this layer clear + // avoids stacking multiple identical translucent backgrounds (which looks opaque). + layer.backgroundColor = NSColor.clear.cgColor + layer.isOpaque = false CATransaction.commit() } terminalSurface?.hostedView.setBackgroundColor(color) + if GhosttyApp.shared.backgroundLogEnabled { + let signature = "\(color.hexString()):\(String(format: "%.3f", color.alphaComponent))" + if signature != lastLoggedSurfaceBackgroundSignature { + lastLoggedSurfaceBackgroundSignature = signature + let hasOverride = backgroundColor != nil + let overrideHex = backgroundColor?.hexString() ?? "nil" + let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString() + let source = hasOverride ? "surfaceOverride" : "defaultBackground" + GhosttyApp.shared.logBackground( + "surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + ) + } + } + } + + // Theme/background application is window-local. During cross-window workspace + // switches (e.g. jump-to-unread), the global active tab manager can lag behind. + // Prefer the owning window's selected workspace when available. + static func shouldApplyWindowBackground( + surfaceTabId: UUID?, + owningManagerExists: Bool, + owningSelectedTabId: UUID?, + activeSelectedTabId: UUID? + ) -> Bool { + guard let surfaceTabId else { return true } + if owningManagerExists { + guard let owningSelectedTabId else { return true } + return owningSelectedTabId == surfaceTabId + } + if let activeSelectedTabId { + return activeSelectedTabId == surfaceTabId + } + return true } func applyWindowBackgroundIfActive() { guard let window else { return } - if let tabId, let selectedId = AppDelegate.shared?.tabManager?.selectedTabId, tabId != selectedId { + let appDelegate = AppDelegate.shared + let owningManager = tabId.flatMap { appDelegate?.tabManagerFor(tabId: $0) } + let owningSelectedTabId = owningManager?.selectedTabId + let activeSelectedTabId = owningManager == nil ? appDelegate?.tabManager?.selectedTabId : nil + guard Self.shouldApplyWindowBackground( + surfaceTabId: tabId, + owningManagerExists: owningManager != nil, + owningSelectedTabId: owningSelectedTabId, + activeSelectedTabId: activeSelectedTabId + ) else { return } applySurfaceBackground() let color = effectiveBackgroundColor() - if cmuxShouldUseTransparentBackgroundWindow() { - window.backgroundColor = .clear + if cmuxShouldUseClearWindowBackground(for: color.alphaComponent) { + window.backgroundColor = cmuxTransparentWindowBaseColor() window.isOpaque = false } else { window.backgroundColor = color window.isOpaque = color.alphaComponent >= 1.0 } if GhosttyApp.shared.backgroundLogEnabled { - GhosttyApp.shared.logBackground("applied window background tab=\(tabId?.uuidString ?? "unknown") color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") + let signature = "\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent) ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent))" + if signature != lastLoggedWindowBackgroundSignature { + lastLoggedWindowBackgroundSignature = signature + let hasOverride = backgroundColor != nil + let overrideHex = backgroundColor?.hexString() ?? "nil" + let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString() + let source = hasOverride ? "surfaceOverride" : "defaultBackground" + GhosttyApp.shared.logBackground( + "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) transparent=\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent)) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + ) + } } } @@ -1847,13 +3524,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } func attachSurface(_ surface: TerminalSurface) { - appliedColorScheme = nil + let isSameSurface = terminalSurface === surface + let isAlreadyAttached = surface.isAttached(to: self) + if !isSameSurface { + appliedColorScheme = nil + } terminalSurface = surface tabId = surface.tabId - surface.attachToView(self) - updateSurfaceSize() + if !isAlreadyAttached { + surface.attachToView(self) + } + surface.setKeyboardCopyModeActive(keyboardCopyModeActive) + if !isAlreadyAttached { + updateSurfaceSize() + } applySurfaceBackground() - applySurfaceColorScheme(force: true) + applySurfaceColorScheme(force: !isSameSurface || !isAlreadyAttached) } override func viewDidMoveToWindow() { @@ -1888,25 +3574,33 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ghostty_surface_set_display_id(surface, displayID) } - // Recompute from current bounds after layout, not stale pending sizes. + // Recompute from current bounds after layout. Pending size is only a fallback + // when we don't have usable bounds (e.g. detached/off-window transitions). superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() - let targetSize: CGSize = { - let current = bounds.size - if current.width > 0, current.height > 0 { - return current - } - return pendingSurfaceSize ?? current - }() - updateSurfaceSize(size: targetSize) + updateSurfaceSize() applySurfaceBackground() applySurfaceColorScheme(force: true) + GhosttyApp.shared.synchronizeThemeWithAppearance( + effectiveAppearance, + source: "surface.viewDidMoveToWindow" + ) applyWindowBackgroundIfActive() } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() + if GhosttyApp.shared.backgroundLogEnabled { + let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) + GhosttyApp.shared.logBackground( + "surface appearance changed tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil")" + ) + } applySurfaceColorScheme() + GhosttyApp.shared.synchronizeThemeWithAppearance( + effectiveAppearance, + source: "surface.viewDidChangeEffectiveAppearance" + ) } fileprivate func updateOcclusionState() { @@ -1932,9 +3626,68 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override var isOpaque: Bool { false } - private func updateSurfaceSize(size: CGSize? = nil) { - guard let terminalSurface = terminalSurface else { return } - let size = size ?? bounds.size + private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize { + if let size, + size.width > 0, + size.height > 0 { + return size + } + + let currentBounds = bounds.size + if currentBounds.width > 0, currentBounds.height > 0 { + return currentBounds + } + + if let pending = pendingSurfaceSize, + pending.width > 0, + pending.height > 0 { + return pending + } + + return currentBounds + } + + private static func hasTabDragPasteboardTypes() -> Bool { + let types = NSPasteboard(name: .drag).types ?? [] + return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType) + } + + private static func isDragResizeEvent(_ eventType: NSEvent.EventType?) -> Bool { + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } + + private static func shouldDeferSurfaceResizeForActiveDrag() -> Bool { + // The drag pasteboard can retain tab-transfer UTIs briefly after a split command + // or other layout churn. Only defer terminal resizes while an actual drag event + // is in flight; otherwise pre-existing panes can stay stuck at their old size. + guard hasTabDragPasteboardTypes() else { return false } + return isDragResizeEvent(NSApp.currentEvent?.type) + } + + private func activeSurfaceResizeDeferralReason() -> String? { + return Self.shouldDeferSurfaceResizeForActiveDrag() ? "tabDrag" : nil + } + + private func scheduleDeferredSurfaceSizeRetryIfNeeded() { + guard window != nil else { return } + guard !deferredSurfaceSizeRetryQueued else { return } + deferredSurfaceSizeRetryQueued = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.deferredSurfaceSizeRetryQueued = false + _ = self.updateSurfaceSize() + } + } + + @discardableResult + private func updateSurfaceSize(size: CGSize? = nil) -> Bool { + guard let terminalSurface = terminalSurface else { return false } + let size = resolvedSurfaceSize(preferred: size) guard size.width > 0 && size.height > 0 else { #if DEBUG let signature = "nonPositive-\(Int(size.width))x\(Int(size.height))" @@ -1947,9 +3700,25 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } pendingSurfaceSize = size + if let deferralReason = activeSurfaceResizeDeferralReason() { + scheduleDeferredSurfaceSizeRetryIfNeeded() +#if DEBUG + let signature = "\(deferralReason)-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))" + if lastSizeSkipSignature != signature { + dlog( + "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(deferralReason) " + + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + + "inWindow=\(window != nil ? 1 : 0)" + ) + lastSizeSkipSignature = signature + } +#endif + return false + } + guard let window else { #if DEBUG let signature = "noWindow-\(Int(size.width))x\(Int(size.height))" @@ -1961,7 +3730,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } // First principles: derive pixel size from AppKit's backing conversion for the current @@ -1979,7 +3748,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } #if DEBUG if lastSizeSkipSignature != nil { @@ -1994,32 +3763,53 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let xScale = backingSize.width / size.width let yScale = backingSize.height / size.height let layerScale = max(1.0, window.backingScaleFactor) + let drawablePixelSize = CGSize( + width: floor(max(0, backingSize.width)), + height: floor(max(0, backingSize.height)) + ) + var didChange = false CATransaction.begin() CATransaction.setDisableActions(true) + if let layer, !nearlyEqual(layer.contentsScale, layerScale) { + didChange = true + } layer?.contentsScale = layerScale + layer?.masksToBounds = true if let metalLayer = layer as? CAMetalLayer { - metalLayer.drawableSize = backingSize + if drawablePixelSize != lastDrawableSize || metalLayer.drawableSize != drawablePixelSize { + if metalLayer.drawableSize != drawablePixelSize { + didChange = true + } + if metalLayer.drawableSize != drawablePixelSize { + metalLayer.drawableSize = drawablePixelSize + } + lastDrawableSize = drawablePixelSize + } } CATransaction.commit() - terminalSurface.updateSize( + let surfaceSizeChanged = terminalSurface.updateSize( width: size.width, height: size.height, xScale: xScale, yScale: yScale, - layerScale: layerScale + layerScale: layerScale, + backingSize: backingSize ) - pendingSurfaceSize = nil + return didChange || surfaceSizeChanged } - fileprivate func pushTargetSurfaceSize(_ size: CGSize) { + @discardableResult + fileprivate func pushTargetSurfaceSize(_ size: CGSize) -> Bool { updateSurfaceSize(size: size) } - /// Force a full size recalculation and Metal layer refresh. - /// Resets cached metrics so updateSurfaceSize() re-runs unconditionally. - func forceRefreshSurface() { + /// Force a full size reconciliation for the current bounds. + /// Keep the drawable-size cache intact so redundant refresh paths do not + /// reallocate Metal drawables when the pixel size is unchanged. + @discardableResult + func forceRefreshSurface() -> Bool { updateSurfaceSize() } @@ -2048,10 +3838,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ? GHOSTTY_COLOR_SCHEME_DARK : GHOSTTY_COLOR_SCHEME_LIGHT if !force, appliedColorScheme == scheme { + if GhosttyApp.shared.backgroundLogEnabled { + let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" + GhosttyApp.shared.logBackground( + "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=false" + ) + } return } ghostty_surface_set_color_scheme(surface, scheme) appliedColorScheme = scheme + if GhosttyApp.shared.backgroundLogEnabled { + let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" + GhosttyApp.shared.logBackground( + "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=true" + ) + } } @discardableResult @@ -2073,36 +3875,318 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + @discardableResult + func toggleKeyboardCopyMode() -> Bool { + guard surface != nil else { return false } + setKeyboardCopyModeActive(!keyboardCopyModeActive) + if !keyboardCopyModeActive, let surface { + _ = ghostty_surface_clear_selection(surface) + } + return true + } + + private func setKeyboardCopyModeActive(_ active: Bool) { + keyboardCopyModeInputState.reset() + keyboardCopyModeVisualActive = false + keyboardCopyModeActive = active + if active, let surface { + keyboardCopyModeViewportRow = keyboardCopyModeSelectionAnchor(surface: surface)?.row + _ = ghostty_surface_clear_selection(surface) + if keyboardCopyModeViewportRow == nil { + keyboardCopyModeViewportRow = keyboardCopyModeImeViewportRow(surface: surface) + } + // Create a 1-cell selection at the terminal cursor to serve as a + // visible cursor indicator in copy mode. + _ = ghostty_surface_select_cursor_cell(surface) + } else { + keyboardCopyModeViewportRow = nil + } + terminalSurface?.setKeyboardCopyModeActive(active) + } + + private func performBindingAction(_ action: String, repeatCount: Int) { + let count = terminalKeyboardCopyModeClampCount(repeatCount) + for _ in 0 ..< count { + _ = performBindingAction(action) + } + } + + private func currentKeyboardCopyModeViewportRow(surface: ghostty_surface_t) -> Int { + let rows = max(Int(ghostty_surface_size(surface).rows), 1) + let fallback = rows - 1 + return max(0, min(rows - 1, keyboardCopyModeViewportRow ?? fallback)) + } + + private func keyboardCopyModeImeViewportRow(surface: ghostty_surface_t) -> Int { + let rows = max(Int(ghostty_surface_size(surface).rows), 1) + var x: Double = 0 + var y: Double = 0 + var width: Double = 0 + var height: Double = 0 + ghostty_surface_ime_point(surface, &x, &y, &width, &height) + return terminalKeyboardCopyModeInitialViewportRow( + rows: rows, + imePointY: y, + imeCellHeight: height + ) + } + + private func keyboardCopyModeSelectionAnchor(surface: ghostty_surface_t) -> (row: Int, y: Double)? { + let size = ghostty_surface_size(surface) + guard size.rows > 0, size.columns > 0 else { return nil } + guard ghostty_surface_select_cursor_cell(surface) else { return nil } + + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + + let rows = max(Int(size.rows), 1) + let cols = max(Int(size.columns), 1) + let rawRow = Int(text.offset_start) / cols + let clampedRow = max(0, min(rows - 1, rawRow)) + return (row: clampedRow, y: text.tl_px_y) + } + + private func refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: ghostty_surface_t) { + // In visual mode the user owns the selection range; don't disturb it. + // Outside visual mode we keep a 1-cell cursor selection for visibility, + // so we still need to refresh the viewport row after scrolling. + guard !keyboardCopyModeVisualActive else { return } + guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return } + keyboardCopyModeViewportRow = anchor.row + // Preserve the visible cursor indicator. + _ = ghostty_surface_select_cursor_cell(surface) + } + + private func copyCurrentViewportLinesToClipboard( + surface: ghostty_surface_t, + startRow: Int, + lineCount: Int + ) -> Bool { + let clampedCount = terminalKeyboardCopyModeClampCount(lineCount) + let rows = max(Int(ghostty_surface_size(surface).rows), 1) + let targetRow = max(0, min(rows - 1, startRow)) + let endRow = min(rows - 1, targetRow + clampedCount - 1) + guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { + return false + } + _ = ghostty_surface_clear_selection(surface) + + var imeX: Double = 0 + var imeY: Double = 0 + var imeWidth: Double = 0 + var imeHeight: Double = 0 + ghostty_surface_ime_point(surface, &imeX, &imeY, &imeWidth, &imeHeight) + let cellHeight = imeHeight > 0 ? imeHeight : max(bounds.height / Double(rows), 1) + let yMax = max(bounds.height - 1, 0) + + let startRawY = anchor.y + (Double(targetRow - anchor.row) * cellHeight) + let endRawY = anchor.y + (Double(endRow - anchor.row) * cellHeight) + let startY = max(0, min(startRawY, yMax)) + let endY = max(0, min(endRawY, yMax)) + let xMax = max(bounds.width - 1, 0) + let startX = min(1, xMax) + let endX = xMax + + let mods = ghostty_input_mods_e(rawValue: GHOSTTY_MODS_NONE.rawValue) ?? GHOSTTY_MODS_NONE + ghostty_surface_mouse_pos(surface, startX, startY, mods) + guard ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else { + return false + } + defer { + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + } + ghostty_surface_mouse_pos(surface, endX, endY, mods) + guard ghostty_surface_has_selection(surface) else { return false } + + return performBindingAction("copy_to_clipboard") + } + + private func handleKeyboardCopyModeIfNeeded(_ event: NSEvent, surface: ghostty_surface_t) -> Bool { + guard keyboardCopyModeActive else { return false } + + if terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: event.modifierFlags) { + keyboardCopyModeInputState.reset() + return false + } + + // Use the visual-mode flag instead of raw has_selection so that the + // 1-cell cursor selection doesn't make every motion behave as visual. + let hasSelection = keyboardCopyModeVisualActive + let resolution = terminalKeyboardCopyModeResolve( + keyCode: event.keyCode, + charactersIgnoringModifiers: event.charactersIgnoringModifiers, + modifierFlags: event.modifierFlags, + hasSelection: hasSelection, + state: &keyboardCopyModeInputState + ) + guard case let .perform(action, count) = resolution else { + return true + } + + switch action { + case .exit: + _ = ghostty_surface_clear_selection(surface) + setKeyboardCopyModeActive(false) + case .startSelection: + keyboardCopyModeVisualActive = true + case .clearSelection: + keyboardCopyModeVisualActive = false + _ = ghostty_surface_clear_selection(surface) + // Re-create 1-cell cursor at terminal cursor position. + _ = ghostty_surface_select_cursor_cell(surface) + case .copyAndExit: + _ = performBindingAction("copy_to_clipboard") + _ = ghostty_surface_clear_selection(surface) + setKeyboardCopyModeActive(false) + case .copyLineAndExit: + let startRow = currentKeyboardCopyModeViewportRow(surface: surface) + _ = copyCurrentViewportLinesToClipboard( + surface: surface, + startRow: startRow, + lineCount: count + ) + _ = ghostty_surface_clear_selection(surface) + setKeyboardCopyModeActive(false) + case let .scrollLines(delta): + _ = performBindingAction("scroll_page_lines:\(delta * count)") + refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) + case let .scrollPage(delta): + performBindingAction(delta > 0 ? "scroll_page_down" : "scroll_page_up", repeatCount: count) + refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) + case let .scrollHalfPage(delta): + let fraction = delta > 0 ? 0.5 : -0.5 + performBindingAction("scroll_page_fractional:\(fraction)", repeatCount: count) + refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) + case .scrollToTop: + keyboardCopyModeViewportRow = 0 + _ = performBindingAction("scroll_to_top") + case .scrollToBottom: + keyboardCopyModeViewportRow = max(Int(ghostty_surface_size(surface).rows) - 1, 0) + _ = performBindingAction("scroll_to_bottom") + case let .jumpToPrompt(delta): + _ = performBindingAction("jump_to_prompt:\(delta * count)") + refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) + case .startSearch: + _ = performBindingAction("start_search") + case .searchNext: + performBindingAction("navigate_search:next", repeatCount: count) + refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) + case .searchPrevious: + performBindingAction("navigate_search:previous", repeatCount: count) + refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) + case let .adjustSelection(direction): + performBindingAction("adjust_selection:\(direction.rawValue)", repeatCount: count) + } + return true + } + // MARK: - Input Handling @IBAction func copy(_ sender: Any?) { _ = performBindingAction("copy_to_clipboard") } + // MARK: - Clipboard paste + @IBAction func paste(_ sender: Any?) { _ = performBindingAction("paste_from_clipboard") } + /// Pastes clipboard text as plain text, stripping any rich formatting. @IBAction func pasteAsPlainText(_ sender: Any?) { _ = performBindingAction("paste_from_clipboard") } + /// Validates whether edit menu items (copy, paste, split) should be enabled. func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { switch item.action { case #selector(copy(_:)): guard let surface = surface else { return false } return ghostty_surface_has_selection(surface) - case #selector(paste(_:)), #selector(pasteAsPlainText(_:)): + case #selector(paste(_:)): return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) + case #selector(pasteAsPlainText(_:)): + return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) + case #selector(splitHorizontally(_:)), #selector(splitVertically(_:)): + return canSplitCurrentSurface() default: return true } } + // MARK: - Accessibility + + /// Expose the terminal surface as an editable accessibility element. + /// Voice input tools frequently target AX text areas for text insertion. + override func isAccessibilityElement() -> Bool { + true + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .textArea + } + + override func accessibilityHelp() -> String? { + "Terminal content area" + } + + override func accessibilityValue() -> Any? { + // We don't keep a full terminal text snapshot in this layer. + // Expose selected text when available; otherwise provide an empty value + // so AX clients still treat this as an editable text area. + accessibilitySelectedText() ?? "" + } + + override func setAccessibilityValue(_ value: Any?) { + let content: String + switch value { + case let v as NSAttributedString: + content = v.string + case let v as String: + content = v + default: + return + } + + guard !content.isEmpty else { return } + +#if DEBUG + dlog("ime.ax.setValue len=\(content.count)") +#endif + + let inject = { + self.insertText(content, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + if Thread.isMainThread { + inject() + } else { + DispatchQueue.main.async(execute: inject) + } + } + + override func accessibilitySelectedTextRange() -> NSRange { + selectedRange() + } + + override func accessibilitySelectedText() -> String? { + guard let surface = surface else { return nil } + + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + + guard let ptr = text.text, text.text_len > 0 else { return nil } + let selectedData = Data(bytes: ptr, count: Int(text.text_len)) + let selected = String(decoding: selectedData, as: UTF8.self) + return selected.isEmpty ? nil : selected + } + override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() + var shouldApplySurfaceFocus = false if result { // If we become first responder before the ghostty surface exists (e.g. during // split/tab creation while the surface is still being created), record the desired focus. @@ -2122,11 +4206,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // focus/selection can converge. Previously this was gated on `surface != nil`, which // allowed a mismatch where AppKit focus moved but the UI focus indicator (bonsplit) // stayed behind. - if isVisibleInUI { + let hiddenInHierarchy = isHiddenOrHasHiddenAncestor + if isVisibleInUI && hasUsableFocusGeometry && !hiddenInHierarchy { + shouldApplySurfaceFocus = true onFocus?() + } else if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) { +#if DEBUG + dlog( + "focus.firstResponder SUPPRESSED (hidden_or_tiny) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) hidden=\(hiddenInHierarchy ? 1 : 0)" + ) +#endif } } - if result, let surface = ensureSurfaceReadyForInput() { + if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput() { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") @@ -2190,6 +4283,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var keyTextAccumulatorForTesting: [String]? { keyTextAccumulator } + func shouldSuppressShiftSpaceFallbackTextForTesting(event: NSEvent, markedTextBefore: Bool) -> Bool { + shouldSuppressShiftSpaceFallbackText(event: event, markedTextBefore: markedTextBefore) + } // Test-only IME point override so firstRect behavior can be regression tested. private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? @@ -2206,10 +4302,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG private func recordKeyLatency(path: String, event: NSEvent) { guard Self.keyLatencyProbeEnabled else { return } - guard event.timestamp > 0 else { return } - let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000) - let delayText = String(format: "%.2f", delayMs) - dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") + CmuxTypingTiming.logEventDelay(path: path, event: event) } #endif @@ -2218,16 +4311,34 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Intentionally empty - prevents system beep on unhandled key commands } + /// Some third-party voice input apps inject committed text by sending the + /// responder-chain `insertText:` action (single-argument form). + /// Route that into our NSTextInputClient path so text lands in the terminal. + override func insertText(_ insertString: Any) { + insertText(insertString, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + override func performKeyEquivalent(with event: NSEvent) -> Bool { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.performKeyEquivalent", + startedAt: typingTimingStart, + event: event + ) + } +#endif guard event.type == .keyDown else { return false } guard let fr = window?.firstResponder as? NSView, fr === self || fr.isDescendant(of: self) else { return false } guard let surface = ensureSurfaceReadyForInput() else { return false } - // If the IME is composing (marked text present), don't intercept key - // events for bindings — let them flow through to keyDown so the input - // method can process them normally. - if hasMarkedText() { + // If the IME is composing (marked text present) and the key has no Cmd + // modifier, don't intercept — let it flow through to keyDown so the input + // method can process it normally. Cmd-based shortcuts should still work + // during composition since Cmd is never part of IME input sequences. + if hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { return false } @@ -2339,10 +4450,79 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func keyDown(with event: NSEvent) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + let phaseTotalStart = ProcessInfo.processInfo.systemUptime + var ensureSurfaceMs: Double = 0 + var dismissNotificationMs: Double = 0 + var keyboardCopyModeMs: Double = 0 + var interpretMs: Double = 0 + var syncPreeditMs: Double = 0 + var ghosttySendMs: Double = 0 + var refreshMs: Double = 0 + defer { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "terminal.keyDown.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [ + ("ensureSurfaceMs", ensureSurfaceMs), + ("dismissNotificationMs", dismissNotificationMs), + ("keyboardCopyModeMs", keyboardCopyModeMs), + ("interpretMs", interpretMs), + ("syncPreeditMs", syncPreeditMs), + ("ghosttySendMs", ghosttySendMs), + ("refreshMs", refreshMs), + ], + extra: "marked=\(hasMarkedText() ? 1 : 0)" + ) + CmuxTypingTiming.logDuration(path: "terminal.keyDown", startedAt: typingTimingStart, event: event) + } + let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime +#endif guard let surface = ensureSurfaceReadyForInput() else { +#if DEBUG + ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 +#endif super.keyDown(with: event) return } +#if DEBUG + ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 +#endif + if let terminalSurface { +#if DEBUG + let dismissNotificationStart = ProcessInfo.processInfo.systemUptime +#endif + AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction( + tabId: terminalSurface.tabId, + surfaceId: terminalSurface.id + ) +#if DEBUG + dismissNotificationMs = (ProcessInfo.processInfo.systemUptime - dismissNotificationStart) * 1000.0 +#endif + } + if event.keyCode != 53 { + endFindEscapeSuppression() + } + if shouldConsumeSuppressedFindEscape(event) { + return + } +#if DEBUG + let keyboardCopyModeStart = ProcessInfo.processInfo.systemUptime +#endif + if handleKeyboardCopyModeIfNeeded(event, surface: surface) { +#if DEBUG + keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0 +#endif + keyboardCopyModeConsumedKeyUps.insert(event.keyCode) + return + } +#if DEBUG + keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0 +#endif #if DEBUG recordKeyLatency(path: "keyDown", event: event) #endif @@ -2366,7 +4546,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // AppKit text interpretation and send a single deterministic Ghostty key event. // This avoids intermittent drops after rapid split close/reparent transitions. let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.control) && !flags.contains(.command) && !flags.contains(.option) { + if flags.contains(.control) && !flags.contains(.command) && !flags.contains(.option) && !hasMarkedText() { ghostty_surface_set_focus(surface, true) var keyEvent = ghostty_input_key_s() keyEvent.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS @@ -2377,16 +4557,51 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) let text = (event.charactersIgnoringModifiers ?? event.characters ?? "") + let handled: Bool if text.isEmpty { keyEvent.text = nil - _ = ghostty_surface_key(surface, keyEvent) + #if DEBUG + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + handled = sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.ctrlGhosttySend", + event: event + ) + ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + #else + handled = ghostty_surface_key(surface, keyEvent) + #endif } else { - text.withCString { ptr in + #if DEBUG + let sendTimingStart = CmuxTypingTiming.start() + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + #endif + handled = text.withCString { ptr in keyEvent.text = ptr - _ = ghostty_surface_key(surface, keyEvent) + return ghostty_surface_key(surface, keyEvent) } + #if DEBUG + ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "terminal.keyDown.ctrlGhosttySend", + startedAt: sendTimingStart, + event: event, + extra: "handled=\(handled ? 1 : 0)" + ) + #endif } - return +#if DEBUG + dlog( + "key.ctrl path=ghostty surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "handled=\(handled ? 1 : 0) keyCode=\(event.keyCode) chars=\(cmuxScalarHex(event.characters)) " + + "ign=\(cmuxScalarHex(event.charactersIgnoringModifiers)) mods=\(event.modifierFlags.rawValue)" + ) +#endif + // If Ghostty handled the key (action/encoding), we're done. + // If not (e.g. `ignore` keybind), fall through to interpretKeyEvents + // so the IME gets a chance to process this event. + if handled { return } } let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS @@ -2441,12 +4656,52 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // so we can detect when composition ends. let markedTextBefore = markedText.length > 0 + // Capture the keyboard layout ID before interpretation so we can + // detect if an IME changed it (e.g. toggling input methods). + // We only check when not already in a preedit state. + let keyboardIdBefore: String? = if (!markedTextBefore) { + KeyboardLayout.id + } else { + nil + } + // Let the input system handle the event (for IME, dead keys, etc.) +#if DEBUG + let interpretTimingStart = CmuxTypingTiming.start() + let interpretPhaseStart = ProcessInfo.processInfo.systemUptime +#endif interpretKeyEvents([translationEvent]) +#if DEBUG + interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "terminal.keyDown.interpretKeyEvents", + startedAt: interpretTimingStart, + event: event + ) +#endif + + // If the keyboard layout changed, an input method grabbed the event. + // Sync preedit and return without sending the key to Ghostty. + if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id { +#if DEBUG + let syncPreeditStart = ProcessInfo.processInfo.systemUptime +#endif + syncPreedit(clearIfNeeded: markedTextBefore) +#if DEBUG + syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 +#endif + return + } // Sync the preedit state with Ghostty so it can render the IME // composition overlay (e.g. for Korean, Japanese, Chinese input). +#if DEBUG + let syncPreeditStart = ProcessInfo.processInfo.systemUptime +#endif syncPreedit(clearIfNeeded: markedTextBefore) +#if DEBUG + syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 +#endif // Build the key event var keyEvent = ghostty_input_key_s() @@ -2466,59 +4721,182 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyEvent.composing = markedText.length > 0 || markedTextBefore // Use accumulated text from insertText (for IME), or compute text for key - if let accumulated = keyTextAccumulator, !accumulated.isEmpty { + let accumulatedText = keyTextAccumulator ?? [] + var shouldRefreshAfterTextInput = false + if !accumulatedText.isEmpty { // Accumulated text comes from insertText (IME composition result). // These never have "composing" set to true because these are the // result of a composition. keyEvent.composing = false - for text in accumulated { + for text in accumulatedText { if shouldSendText(text) { + shouldRefreshAfterTextInput = true +#if DEBUG + let sendTimingStart = CmuxTypingTiming.start() + let ghosttySendStart = ProcessInfo.processInfo.systemUptime +#endif text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } +#if DEBUG + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "terminal.keyDown.accumulatedGhosttySend", + startedAt: sendTimingStart, + event: event, + extra: "textBytes=\(text.utf8.count)" + ) +#endif } else { keyEvent.text = nil + #if DEBUG + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + _ = sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.accumulatedGhosttySend", + event: event + ) + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + #else _ = ghostty_surface_key(surface, keyEvent) + #endif } } } else { // Get the appropriate text for this key event // For control characters, this returns the unmodified character // so Ghostty's KeyEncoder can handle ctrl encoding + let suppressShiftSpaceFallbackText = + shouldSuppressShiftSpaceFallbackText( + event: translationEvent, + markedTextBefore: markedTextBefore + ) if let text = textForKeyEvent(translationEvent) { - if shouldSendText(text) { + if shouldSendText(text), !suppressShiftSpaceFallbackText { + shouldRefreshAfterTextInput = true +#if DEBUG + let sendTimingStart = CmuxTypingTiming.start() + let ghosttySendStart = ProcessInfo.processInfo.systemUptime +#endif text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } +#if DEBUG + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "terminal.keyDown.ghosttySend", + startedAt: sendTimingStart, + event: event, + extra: "textBytes=\(text.utf8.count)" + ) +#endif } else { keyEvent.text = nil + #if DEBUG + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + _ = sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.ghosttySend", + event: event + ) + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + #else _ = ghostty_surface_key(surface, keyEvent) + #endif } } else { keyEvent.text = nil + #if DEBUG + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + _ = sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.ghosttySend", + event: event + ) + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + #else _ = ghostty_surface_key(surface, keyEvent) + #endif } } + if shouldRefreshAfterTextInput { +#if DEBUG + let refreshStart = ProcessInfo.processInfo.systemUptime +#endif + terminalSurface?.forceRefresh(reason: "keyDown.textInput") +#if DEBUG + refreshMs = (ProcessInfo.processInfo.systemUptime - refreshStart) * 1000.0 +#endif + } + // Rendering is driven by Ghostty's wakeups/renderer. } + @discardableResult + private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool { +#if DEBUG + Self.debugGhosttySurfaceKeyEventObserver?(keyEvent) +#endif + return ghostty_surface_key(surface, keyEvent) + } + +#if DEBUG + @discardableResult + private func sendTimedGhosttyKey( + _ surface: ghostty_surface_t, + _ keyEvent: ghostty_input_key_s, + path: String, + event: NSEvent? = nil, + extra: String? = nil + ) -> Bool { + let timingStart = CmuxTypingTiming.start() + let handled = sendGhosttyKey(surface, keyEvent) + let baseExtra = "handled=\(handled ? 1 : 0)" + let mergedExtra: String + if let extra, !extra.isEmpty { + mergedExtra = "\(baseExtra) \(extra)" + } else { + mergedExtra = baseExtra + } + CmuxTypingTiming.logDuration(path: path, startedAt: timingStart, event: event, extra: mergedExtra) + return handled + } +#endif + override func keyUp(with event: NSEvent) { - guard let surface = surface else { + guard let surface = ensureSurfaceReadyForInput() else { super.keyUp(with: event) return } + if event.keyCode != 53 { + endFindEscapeSuppression() + } + if shouldConsumeSuppressedFindEscape(event) { + endFindEscapeSuppression() + return + } + if event.keyCode == 53 { + endFindEscapeSuppression() + } - var keyEvent = ghostty_input_key_s() + if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil { + return + } + + // Build release events from the same translation path as keyDown so + // consumers that depend on precise key identity (for example Space + // hold/release flows) receive consistent metadata. + var keyEvent = ghosttyKeyEvent(for: event, surface: surface) keyEvent.action = GHOSTTY_ACTION_RELEASE - keyEvent.keycode = UInt32(event.keyCode) - keyEvent.mods = modsFromEvent(event) - keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil keyEvent.composing = false - _ = ghostty_surface_key(surface, keyEvent) + _ = sendGhosttyKey(surface, keyEvent) } override func flagsChanged(with event: NSEvent) { @@ -2558,6 +4936,21 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return ghostty_input_mods_e(rawValue: mods) } + func beginFindEscapeSuppression() { + isFindEscapeSuppressionArmed = true + } + + private func endFindEscapeSuppression() { + isFindEscapeSuppressionArmed = false + } + + private func shouldConsumeSuppressedFindEscape(_ event: NSEvent) -> Bool { + guard event.keyCode == 53 else { return false } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.isEmpty else { return false } + return isFindEscapeSuppressionArmed + } + /// Get the characters for a key event with control character handling. /// When control is pressed, we get the character without the control modifier /// so Ghostty's KeyEncoder can apply its own control character encoding. @@ -2565,10 +4958,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let chars = event.characters, !chars.isEmpty else { return nil } if chars.count == 1, let scalar = chars.unicodeScalars.first { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + // If we have a single control character, return the character without // the control modifier so Ghostty's KeyEncoder can handle it. if scalar.value < 0x20 { - return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) + if flags.contains(.control) { + return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) + } + + // Some AppKit key paths can report Shift+` as a bare ESC control + // character even though the physical key should produce "~". + if scalar.value == 0x1B, + flags == [.shift], + event.charactersIgnoringModifiers == "`" { + return "~" + } } // Private Use Area characters (function keys) should not be sent if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { @@ -2581,7 +4986,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { /// Get the unshifted codepoint for the key event private func unshiftedCodepointFromEvent(_ event: NSEvent) -> UInt32 { - guard let chars = event.characters(byApplyingModifiers: []), + if let layoutChars = KeyboardLayout.character(forKeyCode: event.keyCode), + layoutChars.count == 1, + let layoutScalar = layoutChars.unicodeScalars.first, + layoutScalar.value >= 0x20, + !(layoutScalar.value >= 0xF700 && layoutScalar.value <= 0xF8FF) { + return layoutScalar.value + } + + guard let chars = (event.characters(byApplyingModifiers: []) ?? event.charactersIgnoringModifiers ?? event.characters), let scalar = chars.unicodeScalars.first else { return 0 } return scalar.value } @@ -2591,6 +5004,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return first >= 0x20 } + /// If AppKit consumed Shift+Space for IME/input-source switching, interpretKeyEvents + /// can return without insertText and without a detectable layout ID change. + /// In that case we must not synthesize a literal space fallback. + private func shouldSuppressShiftSpaceFallbackText(event: NSEvent, markedTextBefore: Bool) -> Bool { + guard event.keyCode == 49 else { return false } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags == [.shift] else { return false } + guard !markedTextBefore, markedText.length == 0 else { return false } + return true + } + private func ghosttyKeyEvent(for event: NSEvent, surface: ghostty_surface_t) -> ghostty_input_key_s { var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS @@ -2641,12 +5065,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { case GHOSTTY_KEY_TABLE_ACTIVATE: let namePtr = action.value.activate.name let nameLen = Int(action.value.activate.len) + let name: String if let namePtr, nameLen > 0 { let data = Data(bytes: namePtr, count: nameLen) - if let name = String(data: data, encoding: .utf8) { - keyTables.append(name) - } + name = String(data: data, encoding: .utf8) ?? "" + } else { + name = "" } + keyTables.append(name) case GHOSTTY_KEY_TABLE_DEACTIVATE: _ = keyTables.popLast() case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL: @@ -2654,15 +5080,46 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { default: break } + + terminalSurface?.hostedView.syncKeyStateIndicator(text: currentKeyStateIndicatorText) } // MARK: - Mouse Handling + #if DEBUG + private func debugModifierString(_ flags: NSEvent.ModifierFlags) -> String { + [ + flags.contains(.command) ? "cmd" : nil, + flags.contains(.shift) ? "shift" : nil, + flags.contains(.control) ? "ctrl" : nil, + flags.contains(.option) ? "opt" : nil, + ].compactMap { $0 }.joined(separator: "+") + } + #endif + + private func requestPointerFocusRecovery() { +#if DEBUG + dlog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") +#endif + onFocus?() + } + override func mouseDown(with event: NSEvent) { #if DEBUG - dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") + let debugPoint = convert(event.locationInWindow, from: nil) + dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))") #endif + // Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus` + // callback. Treat pointer-down as explicit focus intent so clicking a ghost pane still + // repairs workspace/pane active state before key routing runs. + requestPointerFocusRecovery() window?.makeFirstResponder(self) + if let terminalSurface { + AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction( + tabId: terminalSurface.tabId, + surfaceId: terminalSurface.id + ) + } guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) @@ -2670,6 +5127,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func mouseUp(with event: NSEvent) { + #if DEBUG + dlog("terminal.mouseUp surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))]") + #endif guard let surface = surface else { return } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) } @@ -2677,10 +5137,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func rightMouseDown(with event: NSEvent) { guard let surface = surface else { return } if !ghostty_surface_mouse_captured(surface) { + requestPointerFocusRecovery() super.rightMouseDown(with: event) return } + requestPointerFocusRecovery() window?.makeFirstResponder(self) let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) @@ -2697,6 +5159,28 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) } + override func otherMouseDown(with event: NSEvent) { + guard event.buttonNumber == 2 else { + super.otherMouseDown(with: event) + return + } + requestPointerFocusRecovery() + window?.makeFirstResponder(self) + guard let surface = surface else { return } + let point = convert(event.locationInWindow, from: nil) + ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, modsFromEvent(event)) + } + + override func otherMouseUp(with event: NSEvent) { + guard event.buttonNumber == 2 else { + super.otherMouseUp(with: event) + return + } + guard let surface = surface else { return } + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, modsFromEvent(event)) + } + override func menu(for event: NSEvent) -> NSMenu? { guard let surface = surface else { return nil } if ghostty_surface_mouse_captured(surface) { @@ -2720,14 +5204,69 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } let pasteItem = menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") pasteItem.target = self + menu.addItem(.separator()) + let splitHorizontallyItem = menu.addItem( + withTitle: "Split Horizontally", + action: #selector(splitHorizontally(_:)), + keyEquivalent: "d" + ) + splitHorizontallyItem.target = self + splitHorizontallyItem.keyEquivalentModifierMask = [.command, .shift] + splitHorizontallyItem.image = NSImage( + systemSymbolName: "rectangle.bottomhalf.inset.filled", + accessibilityDescription: nil + ) + + let splitVerticallyItem = menu.addItem( + withTitle: "Split Vertically", + action: #selector(splitVertically(_:)), + keyEquivalent: "d" + ) + splitVerticallyItem.target = self + splitVerticallyItem.keyEquivalentModifierMask = [.command] + splitVerticallyItem.image = NSImage( + systemSymbolName: "rectangle.righthalf.inset.filled", + accessibilityDescription: nil + ) return menu } + private func canSplitCurrentSurface() -> Bool { + guard let tabId, + let surfaceId = terminalSurface?.id, + let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, + let workspace = manager.tabs.first(where: { $0.id == tabId }) else { + return false + } + return workspace.panels[surfaceId] != nil + } + + @objc private func splitHorizontally(_ sender: Any?) { + _ = splitCurrentSurface(direction: .down) + } + + @objc private func splitVertically(_ sender: Any?) { + _ = splitCurrentSurface(direction: .right) + } + + @discardableResult + private func splitCurrentSurface(direction: SplitDirection) -> Bool { + guard let tabId, + let surfaceId = terminalSurface?.id, + let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { + return false + } + return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + } + @objc private func triggerFlash(_ sender: Any?) { onTriggerFlash?() } override func mouseMoved(with event: NSEvent) { + maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) @@ -2735,11 +5274,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) + maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) } + private func maybeRequestFirstResponderForMouseFocus() { + guard let window else { return } + let alreadyFirstResponder = window.firstResponder === self + let shouldRequest = Self.shouldRequestFirstResponderForMouseFocus( + focusFollowsMouseEnabled: GhosttyApp.shared.focusFollowsMouseEnabled(), + pressedMouseButtons: NSEvent.pressedMouseButtons, + appIsActive: NSApp.isActive, + windowIsKey: window.isKeyWindow, + alreadyFirstResponder: alreadyFirstResponder, + visibleInUI: isVisibleInUI, + hasUsableGeometry: hasUsableFocusGeometry, + hiddenInHierarchy: isHiddenOrHasHiddenAncestor + ) + guard shouldRequest else { return } + window.makeFirstResponder(self) + } + override func mouseExited(with event: NSEvent) { guard let surface = surface else { return } if NSEvent.pressedMouseButtons != 0 { @@ -2805,12 +5362,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { deinit { // Surface lifecycle is managed by TerminalSurface, not the view +#if DEBUG + dlog( + "surface.view.deinit view=\(Unmanaged.passUnretained(self).toOpaque()) " + + "surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0)" + ) +#endif if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) } + if let trackingArea { + removeTrackingArea(trackingArea) + } terminalSurface = nil } @@ -2971,6 +5538,8 @@ enum GhosttyNotificationKey { static let title = "ghostty.title" static let backgroundColor = "ghostty.backgroundColor" static let backgroundOpacity = "ghostty.backgroundOpacity" + static let backgroundEventId = "ghostty.backgroundEventId" + static let backgroundSource = "ghostty.backgroundSource" } extension Notification.Name { @@ -2979,6 +5548,7 @@ extension Notification.Name { static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus") static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload") static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange") + static let browserSearchFocus = Notification.Name("browserSearchFocus") } // MARK: - Scroll View Wrapper (Ghostty-style scrollbar) @@ -2986,23 +5556,23 @@ extension Notification.Name { private final class GhosttyScrollView: NSScrollView { weak var surfaceView: GhosttyNSView? + // Keep keyboard routing on the terminal surface; this wrapper is viewport plumbing. + override var acceptsFirstResponder: Bool { false } + override func scrollWheel(with event: NSEvent) { guard let surfaceView else { super.scrollWheel(with: event) return } - if let surface = surfaceView.terminalSurface?.surface, - ghostty_surface_mouse_captured(surface) { - GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: mouseCaptured -> surface scroll") - if window?.firstResponder !== surfaceView { - window?.makeFirstResponder(surfaceView) - } - surfaceView.scrollWheel(with: event) - } else { - GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: super scroll") - super.scrollWheel(with: event) + // Route wheel gestures to the terminal surface so Ghostty handles scrollback. + // Letting NSScrollView consume these events moves the wrapper viewport itself, + // which causes pane-content drift instead of terminal scrollback movement. + GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: surface scroll") + if window?.firstResponder !== surfaceView { + window?.makeFirstResponder(surfaceView) } + surfaceView.scrollWheel(with: event) } } @@ -3014,7 +5584,34 @@ private final class GhosttyFlashOverlayView: NSView { } } +private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + +func shouldAllowEnsureFocusWindowActivation( + activeTabManager: TabManager?, + targetTabManager: TabManager, + keyWindow: NSWindow?, + mainWindow: NSWindow? +) -> Bool { + activeTabManager === targetTabManager || (keyWindow == nil && mainWindow == nil) +} + final class GhosttySurfaceScrollView: NSView { + enum FlashStyle { + case standardFocus + case notificationDismiss + } + + private enum NotificationRingMetrics { + static let inset: CGFloat = 2 + static let cornerRadius: CGFloat = 6 + } + private let backgroundView: NSView private let scrollView: GhosttyScrollView private let documentView: NSView @@ -3025,21 +5622,47 @@ final class GhosttySurfaceScrollView: NSView { private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer + private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView + private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView + private let keyboardCopyModeBadgeIconView: NSImageView + private let keyboardCopyModeBadgeLabel: NSTextField + private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>? + private var lastSearchOverlayStateID: ObjectIdentifier? private var observers: [NSObjectProtocol] = [] - private var windowObservers: [NSObjectProtocol] = [] - private var isLiveScrolling = false + private var windowObservers: [NSObjectProtocol] = [] + private var isLiveScrolling = false private var lastSentRow: Int? private var isActive = true + private var lastFocusRefreshAt: CFTimeInterval = 0 private var activeDropZone: DropZone? private var pendingDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. + + /// Tracks whether keyboard focus should go to the search field or the terminal + /// when the window becomes key while the find bar is open. + enum SearchFocusTarget { + case searchField + case terminal + } + private(set) var searchFocusTarget: SearchFocusTarget = .searchField + + private static func panelBackgroundFillColor(for terminalBackgroundColor: NSColor) -> NSColor { + // The Ghostty renderer already draws translucent terminal backgrounds. If we paint an + // additional translucent layer here, alpha stacks and appears effectively opaque. + terminalBackgroundColor.alphaComponent < 0.999 ? .clear : terminalBackgroundColor + } + #if DEBUG private var lastDropZoneOverlayLogSignature: String? - private static var flashCounts: [UUID: Int] = [:] - private static var drawCounts: [UUID: Int] = [:] - private static var lastDrawTimes: [UUID: CFTimeInterval] = [:] - private static var presentCounts: [UUID: Int] = [:] + private var lastDragGeometryLogSignature: String? + private var dragLayoutLogSequence: UInt64 = 0 + private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder") + private static var flashCounts: [UUID: Int] = [:] + private static var drawCounts: [UUID: Int] = [:] + private static var lastDrawTimes: [UUID: CFTimeInterval] = [:] + private static var presentCounts: [UUID: Int] = [:] private static var dropOverlayShowCounts: [UUID: Int] = [:] private static var lastPresentTimes: [UUID: CFTimeInterval] = [:] private static var lastContentsKeys: [UUID: String] = [:] @@ -3142,6 +5765,32 @@ final class GhosttySurfaceScrollView: NSView { } #endif + func portalBindingGuardState() -> (surfaceId: UUID?, generation: UInt64?, state: String) { + guard let terminalSurface = surfaceView.terminalSurface else { + return (surfaceId: nil, generation: nil, state: "missingSurface") + } + return ( + surfaceId: terminalSurface.id, + generation: terminalSurface.portalBindingGeneration(), + state: terminalSurface.portalBindingStateLabel() + ) + } + + func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { + guard let terminalSurface = surfaceView.terminalSurface else { return false } + return terminalSurface.canAcceptPortalBinding( + expectedSurfaceId: expectedSurfaceId, + expectedGeneration: expectedGeneration + ) + } + + func releaseOwnedPortalHost(hostId: ObjectIdentifier, reason: String) { + surfaceView.terminalSurface?.releasePortalHostIfOwned( + hostId: hostId, + reason: reason + ) + } + init(surfaceView: GhosttyNSView) { self.surfaceView = surfaceView backgroundView = NSView(frame: .zero) @@ -3152,6 +5801,10 @@ final class GhosttySurfaceScrollView: NSView { notificationRingLayer = CAShapeLayer() flashOverlayView = GhosttyFlashOverlayView(frame: .zero) flashLayer = CAShapeLayer() + keyboardCopyModeBadgeContainerView = GhosttyFlashOverlayView(frame: .zero) + keyboardCopyModeBadgeView = GhosttyPassthroughVisualEffectView(frame: .zero) + keyboardCopyModeBadgeIconView = NSImageView(frame: .zero) + keyboardCopyModeBadgeLabel = NSTextField(labelWithString: terminalKeyboardCopyModeIndicatorText) scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = false @@ -3169,12 +5822,15 @@ final class GhosttySurfaceScrollView: NSView { documentView.addSubview(surfaceView) super.init(frame: .zero) + wantsLayer = true + layer?.masksToBounds = true backgroundView.wantsLayer = true - backgroundView.layer?.backgroundColor = - GhosttyApp.shared.defaultBackgroundColor - .withAlphaComponent(GhosttyApp.shared.defaultBackgroundOpacity) - .cgColor + let initialTerminalBackground = GhosttyApp.shared.defaultBackgroundColor + .withAlphaComponent(GhosttyApp.shared.defaultBackgroundOpacity) + let initialPanelFill = Self.panelBackgroundFillColor(for: initialTerminalBackground) + backgroundView.layer?.backgroundColor = initialPanelFill.cgColor + backgroundView.layer?.isOpaque = initialPanelFill.alphaComponent >= 1.0 addSubview(backgroundView) addSubview(scrollView) inactiveOverlayView.wantsLayer = true @@ -3182,12 +5838,11 @@ final class GhosttySurfaceScrollView: NSView { inactiveOverlayView.isHidden = true addSubview(inactiveOverlayView) dropZoneOverlayView.wantsLayer = true - dropZoneOverlayView.layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.25).cgColor - dropZoneOverlayView.layer?.borderColor = NSColor.controlAccentColor.cgColor + dropZoneOverlayView.layer?.backgroundColor = cmuxAccentNSColor().withAlphaComponent(0.25).cgColor + dropZoneOverlayView.layer?.borderColor = cmuxAccentNSColor().cgColor dropZoneOverlayView.layer?.borderWidth = 2 dropZoneOverlayView.layer?.cornerRadius = 8 dropZoneOverlayView.isHidden = true - addSubview(dropZoneOverlayView) notificationRingOverlayView.wantsLayer = true notificationRingOverlayView.layer?.backgroundColor = NSColor.clear.cgColor notificationRingOverlayView.layer?.masksToBounds = false @@ -3221,6 +5876,64 @@ final class GhosttySurfaceScrollView: NSView { flashLayer.opacity = 0 flashOverlayView.layer?.addSublayer(flashLayer) addSubview(flashOverlayView) + keyboardCopyModeBadgeContainerView.translatesAutoresizingMaskIntoConstraints = false + keyboardCopyModeBadgeContainerView.wantsLayer = true + keyboardCopyModeBadgeContainerView.layer?.masksToBounds = false + keyboardCopyModeBadgeContainerView.layer?.shadowColor = NSColor.black.cgColor + keyboardCopyModeBadgeContainerView.layer?.shadowOpacity = 0.22 + keyboardCopyModeBadgeContainerView.layer?.shadowRadius = 10 + keyboardCopyModeBadgeContainerView.layer?.shadowOffset = CGSize(width: 0, height: 2) + keyboardCopyModeBadgeView.translatesAutoresizingMaskIntoConstraints = false + keyboardCopyModeBadgeView.wantsLayer = true + keyboardCopyModeBadgeView.material = .hudWindow + keyboardCopyModeBadgeView.blendingMode = .withinWindow + keyboardCopyModeBadgeView.state = .active + keyboardCopyModeBadgeView.layer?.cornerRadius = 18 + keyboardCopyModeBadgeView.layer?.masksToBounds = true + keyboardCopyModeBadgeView.layer?.borderWidth = 1 + keyboardCopyModeBadgeView.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor + keyboardCopyModeBadgeView.alphaValue = 0.97 + keyboardCopyModeBadgeIconView.translatesAutoresizingMaskIntoConstraints = false + keyboardCopyModeBadgeIconView.symbolConfiguration = NSImage.SymbolConfiguration( + pointSize: 13, + weight: .regular, + scale: .medium + ) + keyboardCopyModeBadgeIconView.image = NSImage( + systemSymbolName: "keyboard.badge.ellipsis", + accessibilityDescription: terminalKeyTableIndicatorAccessibilityLabel + ) + keyboardCopyModeBadgeIconView.contentTintColor = NSColor.secondaryLabelColor + keyboardCopyModeBadgeLabel.translatesAutoresizingMaskIntoConstraints = false + keyboardCopyModeBadgeLabel.textColor = NSColor.labelColor + keyboardCopyModeBadgeLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + keyboardCopyModeBadgeLabel.lineBreakMode = .byTruncatingTail + keyboardCopyModeBadgeLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + keyboardCopyModeBadgeLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + keyboardCopyModeBadgeContainerView.addSubview(keyboardCopyModeBadgeView) + keyboardCopyModeBadgeView.addSubview(keyboardCopyModeBadgeIconView) + keyboardCopyModeBadgeView.addSubview(keyboardCopyModeBadgeLabel) + NSLayoutConstraint.activate([ + keyboardCopyModeBadgeView.topAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.topAnchor), + keyboardCopyModeBadgeView.bottomAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.bottomAnchor), + keyboardCopyModeBadgeView.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.leadingAnchor), + keyboardCopyModeBadgeView.trailingAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.trailingAnchor), + keyboardCopyModeBadgeView.widthAnchor.constraint(lessThanOrEqualToConstant: 180), + keyboardCopyModeBadgeIconView.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.leadingAnchor, constant: 12), + keyboardCopyModeBadgeIconView.centerYAnchor.constraint(equalTo: keyboardCopyModeBadgeView.centerYAnchor), + keyboardCopyModeBadgeIconView.widthAnchor.constraint(equalToConstant: 18), + keyboardCopyModeBadgeIconView.heightAnchor.constraint(equalToConstant: 18), + keyboardCopyModeBadgeLabel.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeIconView.trailingAnchor, constant: 7), + keyboardCopyModeBadgeLabel.trailingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.trailingAnchor, constant: -14), + keyboardCopyModeBadgeLabel.topAnchor.constraint(equalTo: keyboardCopyModeBadgeView.topAnchor, constant: 8), + keyboardCopyModeBadgeLabel.bottomAnchor.constraint(equalTo: keyboardCopyModeBadgeView.bottomAnchor, constant: -8), + ]) + keyboardCopyModeBadgeContainerView.isHidden = true + addSubview(keyboardCopyModeBadgeContainerView) + NSLayoutConstraint.activate([ + keyboardCopyModeBadgeContainerView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + keyboardCopyModeBadgeContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + ]) scrollView.contentView.postsBoundsChangedNotifications = true observers.append(NotificationCenter.default.addObserver( @@ -3263,6 +5976,20 @@ final class GhosttySurfaceScrollView: NSView { self?.handleScrollbarUpdate(notification) }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttySearchFocus, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, + let surface = notification.object as? TerminalSurface, + surface === self.surfaceView.terminalSurface else { return } + self.searchFocusTarget = .searchField + // Explicitly unfocus the terminal so the cursor stops blinking + // when the search field takes over. + surface.setFocus(false) + }) + observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidUpdateCellSize, object: surfaceView, @@ -3277,8 +6004,16 @@ final class GhosttySurfaceScrollView: NSView { } deinit { +#if DEBUG + dlog( + "surface.hosted.deinit surface=\(debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + + "inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0) " + + "hidden=\(isHidden ? 1 : 0) frame=\(String(format: "%.1fx%.1f", frame.width, frame.height))" + ) +#endif observers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.forEach { NotificationCenter.default.removeObserver($0) } + dropZoneOverlayView.removeFromSuperview() cancelFocusRequest() } @@ -3292,34 +6027,63 @@ final class GhosttySurfaceScrollView: NSView { synchronizeGeometryAndContent() } + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + guard activeDropZone != nil || pendingDropZone != nil else { return } + attachDropZoneOverlayIfNeeded() + if let zone = activeDropZone ?? pendingDropZone { + applyDropZoneOverlayFrame(dropZoneOverlayFrame(for: zone, in: bounds.size)) + } + } + /// Reconcile AppKit geometry with ghostty surface geometry synchronously. /// Used after split topology mutations (close/split) to prevent a stale one-frame /// IOSurface size from being presented after pane expansion. - func reconcileGeometryNow() { + @discardableResult + func reconcileGeometryNow() -> Bool { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.reconcileGeometryNow() } - return + return false } - synchronizeGeometryAndContent() + return synchronizeGeometryAndContent() } - private func synchronizeGeometryAndContent() { + /// Request an immediate terminal redraw after geometry updates so stale IOSurface + /// contents do not remain stretched during live resize churn. + func refreshSurfaceNow(reason: String = "portal.refreshSurfaceNow") { + surfaceView.terminalSurface?.forceRefresh(reason: reason) + } + + @discardableResult + private func synchronizeGeometryAndContent() -> Bool { CATransaction.begin() CATransaction.setDisableActions(true) defer { CATransaction.commit() } - backgroundView.frame = bounds - scrollView.frame = bounds + let previousSurfaceSize = surfaceView.frame.size + _ = setFrameIfNeeded(backgroundView, to: bounds) + _ = setFrameIfNeeded(scrollView, to: bounds) let targetSize = scrollView.bounds.size - surfaceView.frame.size = targetSize - surfaceView.pushTargetSurfaceSize(targetSize) - documentView.frame.size.width = scrollView.bounds.width - inactiveOverlayView.frame = bounds +#if DEBUG + logLayoutDuringActiveDrag(targetSize: targetSize) +#endif + let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize) + _ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame) + let targetDocumentFrame = CGRect( + origin: documentView.frame.origin, + size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height) + ) + _ = setFrameIfNeeded(documentView, to: targetDocumentFrame) + _ = setFrameIfNeeded(inactiveOverlayView, to: bounds) if let zone = activeDropZone { - dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size) + attachDropZoneOverlayIfNeeded() + _ = setFrameIfNeeded( + dropZoneOverlayView, + to: dropZoneOverlayFrame(for: zone, in: bounds.size) + ) } if let pending = pendingDropZone, bounds.width > 2, @@ -3333,14 +6097,138 @@ final class GhosttySurfaceScrollView: NSView { // same initial animation as direct drop-zone activation. setDropZoneOverlay(zone: pending) } - notificationRingOverlayView.frame = bounds - flashOverlayView.frame = bounds + _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) + _ = setFrameIfNeeded(flashOverlayView, to: bounds) updateNotificationRingPath() - updateFlashPath() + updateFlashPath(style: .standardFocus) synchronizeScrollView() synchronizeSurfaceView() + let didCoreSurfaceChange = synchronizeCoreSurface() + return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange } + @discardableResult + private func setFrameIfNeeded(_ view: NSView, to frame: CGRect) -> Bool { + guard !Self.rectApproximatelyEqual(view.frame, frame) else { return false } + view.frame = frame + return true + } + + private func sizeApproximatelyEqual(_ lhs: CGSize, _ rhs: CGSize, epsilon: CGFloat = 0.0001) -> Bool { + abs(lhs.width - rhs.width) <= epsilon && abs(lhs.height - rhs.height) <= epsilon + } + + private func pointApproximatelyEqual(_ lhs: CGPoint, _ rhs: CGPoint, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.x - rhs.x) <= epsilon && abs(lhs.y - rhs.y) <= epsilon + } + + private func dropZoneOverlayContainerView() -> NSView { + superview ?? self + } + + private func attachDropZoneOverlayIfNeeded() { + // Keep the hover indicator outside the hosted terminal subtree so it stays purely additive + // and cannot invalidate the scroll/surface layout that Ghostty renders into. + let container = dropZoneOverlayContainerView() + if dropZoneOverlayView.superview !== container { + dropZoneOverlayView.removeFromSuperview() + if container === self { + addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) + } else { + container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: self) + } +#if DEBUG + logDropZoneOverlay(event: "attach", zone: activeDropZone ?? pendingDropZone, frame: dropZoneOverlayView.frame) +#endif + return + } + + guard container !== self else { return } + guard let hostedIndex = container.subviews.firstIndex(of: self), + let overlayIndex = container.subviews.firstIndex(of: dropZoneOverlayView), + overlayIndex <= hostedIndex else { return } + container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: self) + } + + private func applyDropZoneOverlayFrame(_ frame: CGRect) { + if Self.rectApproximatelyEqual(dropZoneOverlayView.frame, frame) { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + dropZoneOverlayView.frame = frame + CATransaction.commit() + } + +#if DEBUG + private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } + + private func hasActiveDragLoggingContext() -> Bool { + let pasteboardTypes = NSPasteboard(name: .drag).types + let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true + let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true + let eventType = NSApp.currentEvent?.type + return activeDropZone != nil || + pendingDropZone != nil || + ((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType)) + } + + private func logDragGeometryChange(event: String, old: CGPoint, new: CGPoint) { + guard hasActiveDragLoggingContext() else { return } + + let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil" + let signature = + "\(event)|\(surface)|\(String(format: "%.1f,%.1f", old.x, old.y))|" + + "\(String(format: "%.1f,%.1f", new.x, new.y))|\(overlaySuperviewClass)|\(dropZoneOverlayView.isHidden ? 1 : 0)" + guard lastDragGeometryLogSignature != signature else { return } + lastDragGeometryLogSignature = signature + dlog( + "terminal.dragGeometry event=\(event) surface=\(surface) " + + "old=\(String(format: "%.1f,%.1f", old.x, old.y)) " + + "new=\(String(format: "%.1f,%.1f", new.x, new.y)) " + + "overlaySuper=\(overlaySuperviewClass) " + + "overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " + + "overlayHidden=\(dropZoneOverlayView.isHidden ? 1 : 0)" + ) + } + + private func logLayoutDuringActiveDrag(targetSize: CGSize) { + let pasteboardTypes = NSPasteboard(name: .drag).types + let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true + let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true + let eventType = NSApp.currentEvent?.type + let hasActiveDrag = + activeDropZone != nil || + pendingDropZone != nil || + ((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType)) + guard hasActiveDrag else { return } + + dragLayoutLogSequence &+= 1 + let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + let activeZone = activeDropZone.map { String(describing: $0) } ?? "none" + let pendingZone = pendingDropZone.map { String(describing: $0) } ?? "none" + let event = eventType.map { String(describing: $0) } ?? "nil" + let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "terminal.layout.drag surface=\(surface) seq=\(dragLayoutLogSequence) " + + "activeZone=\(activeZone) pendingZone=\(pendingZone) " + + "hasTabDrag=\(hasTabDrag ? 1 : 0) hasSidebarDrag=\(hasSidebarDrag ? 1 : 0) " + + "event=\(event) inWindow=\(window != nil ? 1 : 0) " + + "overlaySuper=\(overlaySuperviewClass) overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " + + "scrollOrigin=\(String(format: "%.1f,%.1f", scrollView.contentView.bounds.origin.x, scrollView.contentView.bounds.origin.y)) " + + "surfaceOrigin=\(String(format: "%.1f,%.1f", surfaceView.frame.origin.x, surfaceView.frame.origin.y)) " + + "bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "target=\(String(format: "%.1fx%.1f", targetSize.width, targetSize.height))" + ) + } +#endif + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() windowObservers.forEach { NotificationCenter.default.removeObserver($0) } @@ -3351,15 +6239,33 @@ final class GhosttySurfaceScrollView: NSView { object: window, queue: .main ) { [weak self] _ in - self?.applyFirstResponderIfNeeded() + guard let self else { return } + let searchActive = self.surfaceView.terminalSurface?.searchState != nil +#if DEBUG + dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))") +#endif + self.applyFirstResponderIfNeeded() }) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, object: window, queue: .main ) { [weak self] _ in - // No-op: focus is driven by first-responder changes. - _ = self + guard let self, let window = self.window else { return } + let searchActive = self.surfaceView.terminalSurface?.searchState != nil + // Losing key window does not always trigger first-responder resignation, so force + // the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync. + if let fr = window.firstResponder as? NSView, + fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) { +#if DEBUG + dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) resigningFirstResponder") +#endif + window.makeFirstResponder(nil) + } else { +#if DEBUG + dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) firstResponder=\(String(describing: window.firstResponder)) (not terminal, skipping)") +#endif + } }) if window.isKeyWindow { applyFirstResponderIfNeeded() } } @@ -3369,7 +6275,22 @@ final class GhosttySurfaceScrollView: NSView { } func setFocusHandler(_ handler: (() -> Void)?) { - surfaceView.onFocus = handler + guard let handler else { + surfaceView.onFocus = nil + return + } + surfaceView.onFocus = { [weak self] in + // When the terminal surface gains focus (click, tab, etc.), update the + // search focus target so window reactivation restores terminal focus. + if self?.surfaceView.terminalSurface?.searchState != nil { + self?.searchFocusTarget = .terminal + } + handler() + } + } + + func beginFindEscapeSuppression() { + surfaceView.beginFindEscapeSuppression() } func setTriggerFlashHandler(_ handler: (() -> Void)?) { @@ -3378,9 +6299,11 @@ final class GhosttySurfaceScrollView: NSView { func setBackgroundColor(_ color: NSColor) { guard let layer = backgroundView.layer else { return } + let fillColor = Self.panelBackgroundFillColor(for: color) CATransaction.begin() CATransaction.setDisableActions(true) - layer.backgroundColor = color.cgColor + layer.backgroundColor = fillColor.cgColor + layer.isOpaque = fillColor.alphaComponent >= 1.0 CATransaction.commit() } @@ -3401,27 +6324,164 @@ final class GhosttySurfaceScrollView: NSView { return } + let targetHidden = !visible + let targetOpacity: Float = visible ? 1 : 0 + guard notificationRingOverlayView.isHidden != targetHidden || + notificationRingLayer.opacity != targetOpacity else { return } + CATransaction.begin() CATransaction.setDisableActions(true) - notificationRingOverlayView.isHidden = !visible - notificationRingLayer.opacity = visible ? 1 : 0 + notificationRingOverlayView.isHidden = targetHidden + notificationRingLayer.opacity = targetOpacity CATransaction.commit() } + func setSearchOverlay(searchState: TerminalSurface.SearchState?) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.setSearchOverlay(searchState: searchState) + } + return + } + + // Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view. + // SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces. + guard let terminalSurface = surfaceView.terminalSurface, + let searchState else { + let hadOverlay = searchOverlayHostingView != nil + lastSearchOverlayStateID = nil + guard hadOverlay else { return } +#if DEBUG + dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") +#endif + searchOverlayHostingView?.removeFromSuperview() + searchOverlayHostingView = nil + searchFocusTarget = .searchField + return + } + + let searchStateID = ObjectIdentifier(searchState) + if let overlay = searchOverlayHostingView, + lastSearchOverlayStateID == searchStateID, + overlay.superview === self { + if !keyboardCopyModeBadgeContainerView.isHidden { + addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) + } + return + } + + let hadOverlay = searchOverlayHostingView != nil +#if DEBUG + dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") +#endif + + let tabId = terminalSurface.tabId + let surfaceId = terminalSurface.id + let rootView = SurfaceSearchOverlay( + tabId: tabId, + surfaceId: surfaceId, + searchState: searchState, + onMoveFocusToTerminal: { [weak self] in + self?.searchFocusTarget = .terminal + self?.moveFocus() + }, + onNavigateSearch: { [weak terminalSurface] action in + _ = terminalSurface?.performBindingAction(action) + }, + onFieldDidFocus: { [weak self, weak terminalSurface] in + self?.searchFocusTarget = .searchField + terminalSurface?.setFocus(false) + }, + onClose: { [weak self, weak terminalSurface] in + terminalSurface?.searchState = nil + self?.moveFocus() + } + ) + + if let overlay = searchOverlayHostingView { + overlay.rootView = rootView + if overlay.superview !== self { + overlay.removeFromSuperview() + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + if !keyboardCopyModeBadgeContainerView.isHidden { + addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) + } + lastSearchOverlayStateID = searchStateID + return + } + + searchFocusTarget = .searchField + let overlay = NSHostingView(rootView: rootView) + overlay.translatesAutoresizingMaskIntoConstraints = false + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + if !keyboardCopyModeBadgeContainerView.isHidden { + addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) + } + searchOverlayHostingView = overlay + lastSearchOverlayStateID = searchStateID + } + + func syncKeyStateIndicator(text: String?) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.syncKeyStateIndicator(text: text) + } + return + } + + if let text, !text.isEmpty { + keyboardCopyModeBadgeLabel.stringValue = text + keyboardCopyModeBadgeIconView.setAccessibilityLabel(text) + let needsReorder = keyboardCopyModeBadgeContainerView.isHidden + || keyboardCopyModeBadgeContainerView.superview !== self + || subviews.last !== keyboardCopyModeBadgeContainerView + keyboardCopyModeBadgeContainerView.isHidden = false + if needsReorder { + if let overlay = searchOverlayHostingView { + addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) + } else { + addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil) + } + } + return + } + + keyboardCopyModeBadgeIconView.setAccessibilityLabel(terminalKeyTableIndicatorAccessibilityLabel) + keyboardCopyModeBadgeContainerView.isHidden = true + } + private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { let padding: CGFloat = 4 + let localFrame: CGRect switch zone { case .center: - return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2) + localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2) case .left: - return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) + localFrame = CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) case .right: - return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) + localFrame = CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) case .top: - return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding) + localFrame = CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding) case .bottom: - return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding) + localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding) } + + let container = dropZoneOverlayView.superview ?? superview + guard let container, container !== self else { return localFrame } + return container.convert(localFrame, from: self) } private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool { @@ -3451,15 +6511,15 @@ final class GhosttySurfaceScrollView: NSView { activeDropZone = zone pendingDropZone = nil - let previousFrame = dropZoneOverlayView.frame - if let zone { #if DEBUG if window == nil { logDropZoneOverlay(event: "showNoWindow", zone: zone, frame: nil) } #endif + attachDropZoneOverlayIfNeeded() let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size) + let previousFrame = dropZoneOverlayView.frame let isSameFrame = Self.rectApproximatelyEqual(previousFrame, targetFrame) let needsFrameUpdate = !isSameFrame let zoneChanged = previousZone != zone @@ -3472,7 +6532,7 @@ final class GhosttySurfaceScrollView: NSView { dropZoneOverlayView.layer?.removeAllAnimations() if dropZoneOverlayView.isHidden { - dropZoneOverlayView.frame = targetFrame + applyDropZoneOverlayFrame(targetFrame) dropZoneOverlayView.alphaValue = 0 dropZoneOverlayView.isHidden = false #if DEBUG @@ -3542,6 +6602,17 @@ final class GhosttySurfaceScrollView: NSView { let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" let zoneText = zone.map { String(describing: $0) } ?? "none" let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height) + let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil" + let scrollOriginText = String( + format: "%.1f,%.1f", + scrollView.contentView.bounds.origin.x, + scrollView.contentView.bounds.origin.y + ) + let surfaceOriginText = String( + format: "%.1f,%.1f", + surfaceView.frame.origin.x, + surfaceView.frame.origin.y + ) let frameText: String if let frame { frameText = String( @@ -3551,17 +6622,21 @@ final class GhosttySurfaceScrollView: NSView { } else { frameText = "-" } - let signature = "\(event)|\(surface)|\(zoneText)|\(boundsText)|\(frameText)|\(dropZoneOverlayView.isHidden ? 1 : 0)" + let signature = + "\(event)|\(surface)|\(zoneText)|\(boundsText)|\(frameText)|\(overlaySuperviewClass)|" + + "\(scrollOriginText)|\(surfaceOriginText)|\(dropZoneOverlayView.isHidden ? 1 : 0)" guard lastDropZoneOverlayLogSignature != signature else { return } lastDropZoneOverlayLogSignature = signature dlog( "terminal.dropOverlay event=\(event) surface=\(surface) zone=\(zoneText) " + - "hidden=\(dropZoneOverlayView.isHidden ? 1 : 0) bounds=\(boundsText) frame=\(frameText)" + "hidden=\(dropZoneOverlayView.isHidden ? 1 : 0) bounds=\(boundsText) frame=\(frameText) " + + "overlaySuper=\(overlaySuperviewClass) overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " + + "scrollOrigin=\(scrollOriginText) surfaceOrigin=\(surfaceOriginText)" ) } #endif - func triggerFlash() { + func triggerFlash(style: FlashStyle = .standardFocus) { DispatchQueue.main.async { [weak self] in guard let self else { return } #if DEBUG @@ -3569,19 +6644,21 @@ final class GhosttySurfaceScrollView: NSView { Self.recordFlash(for: surfaceId) } #endif - self.updateFlashPath() + self.updateFlashPath(style: style) self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") - animation.values = [0, 1, 0, 1, 0] - animation.keyTimes = [0, 0.25, 0.5, 0.75, 1] - animation.duration = 0.9 - animation.timingFunctions = [ - CAMediaTimingFunction(name: .easeOut), - CAMediaTimingFunction(name: .easeIn), - CAMediaTimingFunction(name: .easeOut), - CAMediaTimingFunction(name: .easeIn) - ] + animation.values = FocusFlashPattern.values.map { NSNumber(value: $0) } + animation.keyTimes = FocusFlashPattern.keyTimes.map { NSNumber(value: $0) } + animation.duration = FocusFlashPattern.duration + animation.timingFunctions = FocusFlashPattern.curves.map { curve in + switch curve { + case .easeIn: + return CAMediaTimingFunction(name: .easeIn) + case .easeOut: + return CAMediaTimingFunction(name: .easeOut) + } + } self.flashLayer.add(animation, forKey: "cmux.flash") } } @@ -3592,9 +6669,11 @@ final class GhosttySurfaceScrollView: NSView { isHidden = !visible #if DEBUG if wasVisible != visible { + let transition = "\(wasVisible ? 1 : 0)->\(visible ? 1 : 0)" + let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.visible", - suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)" + suffix: suffix ) } #endif @@ -3609,23 +6688,32 @@ final class GhosttySurfaceScrollView: NSView { } } + var debugPortalVisibleInUI: Bool { + surfaceView.isVisibleInUI + } + + var debugPortalFrameInWindow: CGRect { + guard window != nil else { return .zero } + return convert(bounds, to: nil) + } + func setActive(_ active: Bool) { let wasActive = isActive isActive = active #if DEBUG if wasActive != active { + let transition = "\(wasActive ? 1 : 0)->\(active ? 1 : 0)" + let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.active", - suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)" + suffix: suffix ) } #endif if active { applyFirstResponderIfNeeded() - } else if let window, - let fr = window.firstResponder as? NSView, - fr === surfaceView || fr.isDescendant(of: surfaceView) { - window.makeFirstResponder(nil) + } else { + resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } } @@ -3638,19 +6726,66 @@ final class GhosttySurfaceScrollView: NSView { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)") } + + private func debugFirstResponderLabel() -> String { + guard let window, let firstResponder = window.firstResponder else { return "nil" } + if let view = firstResponder as? NSView { + if view === surfaceView { + return "surfaceView" + } + if view.isDescendant(of: surfaceView) { + return "surfaceDescendant" + } + return String(describing: type(of: view)) + } + return String(describing: type(of: firstResponder)) + } + + private func debugVisibilityStateSuffix(transition: String) -> String { + let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + let hiddenInHierarchy = (isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor) ? 1 : 0 + let inWindow = window != nil ? 1 : 0 + let hasSuperview = superview != nil ? 1 : 0 + let hostHidden = isHidden ? 1 : 0 + let surfaceHidden = surfaceView.isHidden ? 1 : 0 + let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height) + let frameText = String(format: "%.1fx%.1f", frame.width, frame.height) + let responder = debugFirstResponderLabel() + return + "surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " + + "visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " + + "hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " + + "bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)" + } #endif func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { #if DEBUG - dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") + let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + let searchActive = self.surfaceView.terminalSurface?.searchState != nil + dlog( + "find.moveFocus to=\(surfaceShort) " + + "from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "searchState=\(searchActive ? "active" : "nil") " + + "delayMs=\(Int((delay ?? 0) * 1000))" + ) #endif let work = { [weak self] in guard let self else { return } guard let window = self.window else { return } +#if DEBUG + let before = String(describing: window.firstResponder) +#endif if let previous, previous !== self { _ = previous.surfaceView.resignFirstResponder() } - window.makeFirstResponder(self.surfaceView) + let result = window.makeFirstResponder(self.surfaceView) +#if DEBUG + dlog( + "find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))" + ) +#endif } if let delay, delay > 0 { @@ -3688,8 +6823,37 @@ final class GhosttySurfaceScrollView: NSView { ) } + struct DebugDropZoneOverlayState { + let isHidden: Bool + let frame: CGRect + let isAttachedToHostedView: Bool + let isAttachedToParentContainer: Bool + } + + func debugDropZoneOverlayState() -> DebugDropZoneOverlayState { + DebugDropZoneOverlayState( + isHidden: dropZoneOverlayView.isHidden, + frame: dropZoneOverlayView.frame, + isAttachedToHostedView: dropZoneOverlayView.superview === self, + isAttachedToParentContainer: dropZoneOverlayView.superview === superview + ) + } + + func debugHasSearchOverlay() -> Bool { + guard let overlay = searchOverlayHostingView else { return false } + return overlay.superview === self && !overlay.isHidden + } + + func debugHasKeyboardCopyModeIndicator() -> Bool { + keyboardCopyModeBadgeContainerView.superview === self && !keyboardCopyModeBadgeContainerView.isHidden + } + #endif + fileprivate var hasActiveDropZoneOverlay: Bool { + activeDropZone != nil || pendingDropZone != nil + } + /// Handle file/URL drops, forwarding to the terminal as shell-escaped paths. func handleDroppedURLs(_ urls: [URL]) -> Bool { guard !urls.isEmpty else { return false } @@ -3709,11 +6873,16 @@ final class GhosttySurfaceScrollView: NSView { } #if DEBUG - /// Sends a synthetic Ctrl+D key press directly to the surface view. + /// Sends a synthetic key press/release pair directly to the surface view. /// This exercises the same key path as real keyboard input (ghostty_surface_key), - /// unlike `sendText`, which bypasses key translation. + /// unlike sendText, which bypasses key translation. @discardableResult - func sendSyntheticCtrlDForUITest() -> Bool { + func debugSendSyntheticKeyPressAndReleaseForUITest( + characters: String, + charactersIgnoringModifiers: String, + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags = [] + ) -> Bool { guard let window else { return false } window.makeFirstResponder(surfaceView) @@ -3721,33 +6890,46 @@ final class GhosttySurfaceScrollView: NSView { guard let keyDown = NSEvent.keyEvent( with: .keyDown, location: .zero, - modifierFlags: [.control], + modifierFlags: modifierFlags, timestamp: timestamp, windowNumber: window.windowNumber, context: nil, - characters: "\u{04}", - charactersIgnoringModifiers: "d", + characters: characters, + charactersIgnoringModifiers: charactersIgnoringModifiers, isARepeat: false, - keyCode: 2 + keyCode: keyCode ) else { return false } guard let keyUp = NSEvent.keyEvent( with: .keyUp, location: .zero, - modifierFlags: [.control], + modifierFlags: modifierFlags, timestamp: timestamp + 0.001, windowNumber: window.windowNumber, context: nil, - characters: "\u{04}", - charactersIgnoringModifiers: "d", + characters: characters, + charactersIgnoringModifiers: charactersIgnoringModifiers, isARepeat: false, - keyCode: 2 + keyCode: keyCode ) else { return false } surfaceView.keyDown(with: keyDown) surfaceView.keyUp(with: keyUp) return true } + + /// Sends a synthetic Ctrl+D key press directly to the surface view. + /// This exercises the same key path as real keyboard input (ghostty_surface_key), + /// unlike `sendText`, which bypasses key translation. + @discardableResult + func sendSyntheticCtrlDForUITest(modifierFlags: NSEvent.ModifierFlags = [.control]) -> Bool { + debugSendSyntheticKeyPressAndReleaseForUITest( + characters: "\u{04}", + charactersIgnoringModifiers: "d", + keyCode: 2, + modifierFlags: modifierFlags + ) + } #endif func ensureFocus(for tabId: UUID, surfaceId: UUID, attemptsRemaining: Int = 3) { @@ -3758,10 +6940,32 @@ final class GhosttySurfaceScrollView: NSView { } } + let hasUsablePortalGeometry: Bool = { + let size = bounds.size + return size.width > 1 && size.height > 1 + }() + let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor + guard isActive else { return } - guard surfaceView.terminalSurface?.searchState == nil else { return } guard let window else { return } guard surfaceView.isVisibleInUI else { +#if DEBUG + dlog( + "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=not_visible attempts=\(attemptsRemaining)" + ) +#endif + retry() + return + } + guard !isHiddenForFocus, hasUsablePortalGeometry else { +#if DEBUG + dlog( + "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) attempts=\(attemptsRemaining)" + ) +#endif retry() return } @@ -3788,21 +6992,69 @@ final class GhosttySurfaceScrollView: NSView { return } + // Search focus restoration — only after confirming this is the active tab/pane. + if surfaceView.terminalSurface?.searchState != nil { +#if DEBUG + dlog( + "focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + + "attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))" + ) +#endif + restoreSearchFocus(window: window) + return + } + if let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { + reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder") return } if !window.isKeyWindow { + guard shouldAllowEnsureFocusWindowActivation( + activeTabManager: delegate.tabManager, + targetTabManager: tabManager, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { + return + } window.makeKeyAndOrderFront(nil) } - _ = window.makeFirstResponder(surfaceView) + let result = window.makeFirstResponder(surfaceView) +#if DEBUG + dlog( + "focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " + + "attempts=\(attemptsRemaining)" + ) +#endif if !isSurfaceViewFirstResponder() { retry() + } else { + reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder") } } + private func matchesCurrentTerminalFocusTarget(tabId: UUID, surfaceId: UUID) -> Bool { + guard let delegate = AppDelegate.shared, + let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager, + tabManager.selectedTabId == tabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }), + let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), + let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in + tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) + }) else { + return false + } + + return tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface && + tab.bonsplitController.focusedPaneId == paneId + } + /// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during /// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles. func suppressReparentFocus() { @@ -3821,16 +7073,294 @@ final class GhosttySurfaceScrollView: NSView { return fr === surfaceView || fr.isDescendant(of: surfaceView) } - private func applyFirstResponderIfNeeded() { - guard isActive else { return } - guard surfaceView.isVisibleInUI else { return } - guard surfaceView.terminalSurface?.searchState == nil else { return } - guard let window, window.isKeyWindow else { return } - if let fr = window.firstResponder as? NSView, - fr === surfaceView || fr.isDescendant(of: surfaceView) { + private func reassertTerminalSurfaceFocus(reason: String) { + guard let terminalSurface = surfaceView.terminalSurface else { return } +#if DEBUG + dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") +#endif + terminalSurface.setFocus(true) + refreshSurfaceAfterFocusIfNeeded(reason: reason) + } + + private func refreshSurfaceAfterFocusIfNeeded(reason: String) { + guard let terminalSurface = surfaceView.terminalSurface, + isActive, + let window, + window.isKeyWindow, + surfaceView.isVisibleInUI else { return } + + let now = CACurrentMediaTime() + if now - lastFocusRefreshAt < 0.05 { return } + lastFocusRefreshAt = now +#if DEBUG + dlog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") +#endif + terminalSurface.forceRefresh(reason: "focus.surface.\(reason)") + } + + private func applyFirstResponderIfNeeded() { + let hasUsablePortalGeometry: Bool = { + let size = bounds.size + return size.width > 1 && size.height > 1 + }() + let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor + let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + + guard isActive else { return } + guard surfaceView.isVisibleInUI else { return } + guard !isHiddenForFocus, hasUsablePortalGeometry else { +#if DEBUG + dlog( + "focus.apply.skip surface=\(surfaceShort) " + + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" + ) +#endif + return + } + guard let window, window.isKeyWindow else { return } + guard let tabId = surfaceView.tabId, + let panelId = surfaceView.terminalSurface?.id, + matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else { +#if DEBUG + dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target") +#endif + return + } + if surfaceView.terminalSurface?.searchState != nil { + // Find bar is open. Restore focus based on what the user last intended. + restoreSearchFocus(window: window) + return + } + if let fr = window.firstResponder as? NSView, + fr === surfaceView || fr.isDescendant(of: surfaceView) { + reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder") + return + } + // Don't steal focus from a search overlay on another surface in this window. + if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) { +#if DEBUG + dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused") +#endif + return + } +#if DEBUG + dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))") +#endif window.makeFirstResponder(surfaceView) + if isSurfaceViewFirstResponder() { + reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder") + } + } + + /// Restore focus when window becomes key and the find bar is open. + /// Respects `searchFocusTarget` so Escape-to-terminal intent is preserved across window switches. + private func restoreSearchFocus(window: NSWindow) { + let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + switch searchFocusTarget { + case .searchField: + if let firstResponder = window.firstResponder, + isSearchOverlayOrDescendant(firstResponder), + !isCurrentSurfaceSearchResponder(firstResponder) { + surfaceView.terminalSurface?.setFocus(false) +#if DEBUG + dlog( + "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " + + "reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))" + ) +#endif + return + } + // Explicitly unfocus the terminal so cursor stops blinking immediately. + // The notification observer also does this, but it runs async when posted from main. + surfaceView.terminalSurface?.setFocus(false) + // Post notification — SearchTextFieldRepresentable's Coordinator + // observes it and calls makeFirstResponder on the native NSTextField. + if let terminalSurface = surfaceView.terminalSurface { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) + } +#if DEBUG + dlog( + "find.restoreSearchFocus surface=\(surfaceShort) target=searchField " + + "via=notification firstResponder=\(String(describing: window.firstResponder))" + ) +#endif + case .terminal: + let result = window.makeFirstResponder(surfaceView) +#if DEBUG + dlog( + "find.restoreSearchFocus surface=\(surfaceShort) target=terminal " + + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" + ) +#endif + } + } + + func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent { + if surfaceView.terminalSurface?.searchState != nil { + if let firstResponder = window?.firstResponder as? NSView, + (firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) { + return .surface + } + if let firstResponder = window?.firstResponder, + isCurrentSurfaceSearchResponder(firstResponder) { + return .findField + } + if searchFocusTarget == .searchField { + return .findField + } + } + return .surface + } + + func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent { + if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField { + return .findField + } + return .surface + } + + func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) { + switch intent { + case .surface: + searchFocusTarget = .terminal + case .findField: + guard surfaceView.terminalSurface?.searchState != nil else { return } + searchFocusTarget = .searchField + } +#if DEBUG + dlog( + "find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "target=\(intent == .findField ? "searchField" : "terminal")" + ) +#endif + } + + @discardableResult + func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool { + switch intent { + case .surface: + searchFocusTarget = .terminal + setActive(true) + applyFirstResponderIfNeeded() + return true + case .findField: + guard let terminalSurface = surfaceView.terminalSurface, + terminalSurface.searchState != nil else { + return false + } + searchFocusTarget = .searchField + setActive(true) + if let window { + restoreSearchFocus(window: window) + } else { + terminalSurface.setFocus(false) + NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) + } +#if DEBUG + dlog( + "find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "target=searchField firstResponder=\(String(describing: window?.firstResponder))" + ) +#endif + return true + } + } + + func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? { + if isCurrentSurfaceSearchResponder(responder) { + return .findField + } + + let resolvedResponder: NSResponder + if let editor = responder as? NSTextView, + editor.isFieldEditor, + let editedView = editor.delegate as? NSView { + resolvedResponder = editedView + } else { + resolvedResponder = responder + } + + guard let view = resolvedResponder as? NSView else { return nil } + if view === surfaceView || view.isDescendant(of: surfaceView) { + return .surface + } + return nil + } + + @discardableResult + func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool { + guard let firstResponder = window.firstResponder, + ownedPanelFocusIntent(for: firstResponder) == intent else { + return false + } + + surfaceView.terminalSurface?.setFocus(false) + resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent") +#if DEBUG + dlog( + "focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "target=\(intent == .findField ? "searchField" : "terminal")" + ) +#endif + return true + } + + private func resignOwnedFirstResponderIfNeeded(reason: String) { + guard let window, + let firstResponder = window.firstResponder else { return } + + let ownsSurfaceResponder: Bool = { + guard let view = firstResponder as? NSView else { return false } + return view === surfaceView || view.isDescendant(of: surfaceView) + }() + + guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return } + +#if DEBUG + dlog( + "focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=\(reason) firstResponder=\(String(describing: firstResponder))" + ) +#endif + window.makeFirstResponder(nil) + } + + /// Check if a responder is inside a search overlay hosting view. + /// Handles the AppKit field-editor case: when an NSTextField is being edited, + /// window.firstResponder is the shared NSTextView field editor, not the text field. + private func isSearchOverlayOrDescendant(_ responder: NSResponder) -> Bool { + // If the responder is a field editor, follow its delegate back to the owning control. + if let editor = responder as? NSTextView, + editor.isFieldEditor, + let editedView = editor.delegate as? NSView { + return isSearchOverlayOrDescendant(editedView) + } + + guard let view = responder as? NSView else { return false } + var current: NSView? = view + while let v = current { + if v is NSHostingView<SurfaceSearchOverlay> { return true } + let typeName = String(describing: type(of: v)) + if typeName.contains("BrowserSearchOverlay") { return true } + current = v.superview + } + return false + } + + private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool { + let resolvedResponder: NSResponder + if let editor = responder as? NSTextView, + editor.isFieldEditor, + let editedView = editor.delegate as? NSView { + resolvedResponder = editedView + } else { + resolvedResponder = responder + } + + guard let view = resolvedResponder as? NSView else { return false } + return view.isDescendant(of: self) } #if DEBUG @@ -4107,20 +7637,80 @@ final class GhosttySurfaceScrollView: NSView { private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect + guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return } +#if DEBUG + logDragGeometryChange(event: "surfaceOrigin", old: surfaceView.frame.origin, new: visibleRect.origin) +#endif surfaceView.frame.origin = visibleRect.origin } + /// Match upstream Ghostty behavior: use content area width (excluding non-content + /// regions such as scrollbar space) when telling libghostty the terminal size. + @discardableResult + private func synchronizeCoreSurface() -> Bool { + let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth()) + let height = surfaceView.frame.height + guard width > 0, height > 0 else { return false } + return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) + } + + /// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller. + private func overlayScrollbarInsetWidth() -> CGFloat { + guard scrollView.hasVerticalScroller, scrollView.scrollerStyle == .overlay else { return 0 } + + // If AppKit already reserved non-content width in `contentSize`, avoid double-subtraction. + let alreadyReserved = max(0, scrollView.bounds.width - scrollView.contentSize.width) + if alreadyReserved > 0.5 { return 0 } + + let fallback = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay) + guard let verticalScroller = scrollView.verticalScroller else { return fallback } + + let measuredWidth = verticalScroller.frame.width + if measuredWidth > 0 { + return max(measuredWidth, fallback) + } + + let controlSizeWidth = NSScroller.scrollerWidth( + for: verticalScroller.controlSize, + scrollerStyle: .overlay + ) + return max(controlSizeWidth, fallback) + } + private func updateNotificationRingPath() { - updateOverlayRingPath(layer: notificationRingLayer, bounds: notificationRingOverlayView.bounds) + updateOverlayRingPath( + layer: notificationRingLayer, + bounds: notificationRingOverlayView.bounds, + inset: NotificationRingMetrics.inset, + radius: NotificationRingMetrics.cornerRadius + ) } - private func updateFlashPath() { - updateOverlayRingPath(layer: flashLayer, bounds: flashOverlayView.bounds) + private func updateFlashPath(style: FlashStyle) { + let inset: CGFloat + let radius: CGFloat + switch style { + case .standardFocus: + inset = CGFloat(FocusFlashPattern.ringInset) + radius = CGFloat(FocusFlashPattern.ringCornerRadius) + case .notificationDismiss: + inset = NotificationRingMetrics.inset + radius = NotificationRingMetrics.cornerRadius + } + updateOverlayRingPath( + layer: flashLayer, + bounds: flashOverlayView.bounds, + inset: inset, + radius: radius + ) } - private func updateOverlayRingPath(layer: CAShapeLayer, bounds: CGRect) { - let inset: CGFloat = 2 - let radius: CGFloat = 6 + private func updateOverlayRingPath( + layer: CAShapeLayer, + bounds: CGRect, + inset: CGFloat, + radius: CGFloat + ) { layer.frame = bounds guard bounds.width > inset * 2, bounds.height > inset * 2 else { layer.path = nil @@ -4131,19 +7721,37 @@ final class GhosttySurfaceScrollView: NSView { } private func synchronizeScrollView() { - documentView.frame.size.height = documentHeight() + var didChangeGeometry = false + let targetDocumentHeight = documentHeight() + if abs(documentView.frame.height - targetDocumentHeight) > 0.5 { + documentView.frame.size.height = targetDocumentHeight + didChangeGeometry = true + } if !isLiveScrolling { let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight - scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) + let targetOrigin = CGPoint(x: 0, y: offsetY) + if !pointApproximatelyEqual(scrollView.contentView.bounds.origin, targetOrigin) { +#if DEBUG + logDragGeometryChange( + event: "scrollOrigin", + old: scrollView.contentView.bounds.origin, + new: targetOrigin + ) +#endif + scrollView.contentView.scroll(to: targetOrigin) + didChangeGeometry = true + } lastSentRow = Int(scrollbar.offset) } } - scrollView.reflectScrolledClipView(scrollView.contentView) + if didChangeGeometry { + scrollView.reflectScrolledClipView(scrollView.contentView) + } } private func handleScrollChange() { @@ -4189,6 +7797,9 @@ final class GhosttySurfaceScrollView: NSView { extension GhosttyNSView: NSTextInputClient { fileprivate func sendTextToSurface(_ chars: String) { guard let surface = surface else { return } +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() +#endif #if DEBUG cmuxWriteChildExitProbe( [ @@ -4208,6 +7819,13 @@ extension GhosttyNSView: NSTextInputClient { keyEvent.composing = false _ = ghostty_surface_key(surface, keyEvent) } +#if DEBUG + CmuxTypingTiming.logDuration( + path: "terminal.sendTextToSurface", + startedAt: typingTimingStart, + extra: "textBytes=\(chars.utf8.count)" + ) +#endif } func hasMarkedText() -> Bool { @@ -4224,6 +7842,16 @@ extension GhosttyNSView: NSTextInputClient { } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.setMarkedText", + startedAt: typingTimingStart, + extra: "markedLength=\(markedText.length)" + ) + } +#endif switch string { case let v as NSAttributedString: markedText = NSMutableAttributedString(attributedString: v) @@ -4242,6 +7870,17 @@ extension GhosttyNSView: NSTextInputClient { } func unmarkText() { +#if DEBUG + let hadMarkedText = markedText.length > 0 + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.unmarkText", + startedAt: typingTimingStart, + extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)" + ) + } +#endif if markedText.length > 0 { markedText.mutableString.setString("") syncPreedit() @@ -4252,6 +7891,16 @@ extension GhosttyNSView: NSTextInputClient { /// This tells Ghostty about IME composition text so it can render the /// preedit overlay (e.g. for Korean, Japanese, Chinese input). private func syncPreedit(clearIfNeeded: Bool = true) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.syncPreedit", + startedAt: typingTimingStart, + extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)" + ) + } +#endif guard let surface = surface else { return } if markedText.length > 0 { @@ -4319,8 +7968,17 @@ extension GhosttyNSView: NSTextInputClient { } func insertText(_ string: Any, replacementRange: NSRange) { - guard NSApp.currentEvent != nil else { return } - +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.insertText", + startedAt: typingTimingStart, + event: NSApp.currentEvent, + extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)" + ) + } +#endif // Get the string value var chars = "" switch string { @@ -4335,6 +7993,16 @@ extension GhosttyNSView: NSTextInputClient { // Clear marked text since we're inserting unmarkText() + // Some IME/input-method paths call insertText with an empty payload to + // flush state. There is no terminal text to send in that case. + guard !chars.isEmpty else { return } + +#if DEBUG + if NSApp.currentEvent == nil { + dlog("ime.insertText.noEvent len=\(chars.count)") + } +#endif + // If we have an accumulator, we're in a keyDown event - accumulate the text if keyTextAccumulator != nil { keyTextAccumulator?.append(chars) @@ -4359,6 +8027,7 @@ struct GhosttyTerminalView: NSViewRepresentable { var showsUnreadNotificationRing: Bool = false var inactiveOverlayColor: NSColor = .clear var inactiveOverlayOpacity: Double = 0 + var searchState: TerminalSurface.SearchState? = nil var reattachToken: UInt64 = 0 var onFocus: ((UUID) -> Void)? = nil var onTriggerFlash: (() -> Void)? = nil @@ -4366,31 +8035,57 @@ struct GhosttyTerminalView: NSViewRepresentable { private final class HostContainerView: NSView { var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? + private(set) var geometryRevision: UInt64 = 0 + private var lastReportedGeometryState: GeometryState? + + private struct GeometryState: Equatable { + let frame: CGRect + let bounds: CGRect + let windowNumber: Int? + let superviewID: ObjectIdentifier? + } + + private func currentGeometryState() -> GeometryState { + GeometryState( + frame: frame, + bounds: bounds, + windowNumber: window?.windowNumber, + superviewID: superview.map(ObjectIdentifier.init) + ) + } + + private func notifyGeometryChangedIfNeeded() { + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + lastReportedGeometryState = state + geometryRevision &+= 1 + onGeometryChanged?() + } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() onDidMoveToWindow?() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func layout() { super.layout() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } } @@ -4403,6 +8098,7 @@ struct GhosttyTerminalView: NSViewRepresentable { var desiredPortalZPriority: Int = 0 var lastBoundHostId: ObjectIdentifier? var lastPaneDropZone: DropZone? + var lastSynchronizedHostGeometryRevision: UInt64 = 0 weak var hostedView: GhosttySurfaceScrollView? } @@ -4410,58 +8106,90 @@ struct GhosttyTerminalView: NSViewRepresentable { Coordinator() } + static func shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: Bool, + isBoundToCurrentHost: Bool + ) -> Bool { + // If this update originates from a stale/replaced host while the hosted view is + // already attached elsewhere, do not mutate visibility/active state here. + if isBoundToCurrentHost { return true } + return !hostedViewHasSuperview + } + func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = false + // The actual terminal surface lives in the AppKit portal layer above SwiftUI. + // This empty placeholder should not be walked by the accessibility subsystem. + container.setAccessibilityRole(.none) + container.setAccessibilityElement(false) return container } func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView let coordinator = context.coordinator -#if DEBUG let previousDesiredIsActive = coordinator.desiredIsActive -#endif let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority + let desiredStateChanged = + previousDesiredIsActive != isActive || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredPortalZPriority != portalZPriority coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing coordinator.desiredPortalZPriority = portalZPriority coordinator.hostedView = hostedView #if DEBUG - if previousDesiredIsActive != isActive || - previousDesiredIsVisibleInUI != isVisibleInUI || - previousDesiredPortalZPriority != portalZPriority { + if desiredStateChanged { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " + - "active=\(isActive ? 1 : 0) z=\(portalZPriority)" + "active=\(isActive ? 1 : 0) z=\(portalZPriority) " + + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } else { dlog( "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)" + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " + + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } } #endif + let hostContainer = nsView as? HostContainerView + let hostOwnsPortalNow = hostContainer.map { host in + terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + inWindow: host.window != nil, + bounds: host.bounds, + reason: "update" + ) + } ?? true + // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) - hostedView.setVisibleInUI(isVisibleInUI) - hostedView.setActive(isActive) - hostedView.setInactiveOverlay( - color: inactiveOverlayColor, - opacity: CGFloat(inactiveOverlayOpacity), - visible: showsInactiveOverlay - ) - hostedView.setNotificationRing(visible: showsUnreadNotificationRing) hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) + if hostOwnsPortalNow { + hostedView.setInactiveOverlay( + color: inactiveOverlayColor, + opacity: CGFloat(inactiveOverlayOpacity), + visible: showsInactiveOverlay + ) + hostedView.setNotificationRing(visible: showsUnreadNotificationRing) + hostedView.setSearchOverlay(searchState: searchState) + hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText) + } + let portalExpectedSurfaceId = terminalSurface.id + let portalExpectedGeneration = terminalSurface.portalBindingGeneration() let forwardedDropZone = isVisibleInUI ? paneDropZone : nil #if DEBUG if coordinator.lastPaneDropZone != paneDropZone { @@ -4482,62 +8210,158 @@ struct GhosttyTerminalView: NSViewRepresentable { ) } #endif - hostedView.setDropZoneOverlay(zone: forwardedDropZone) + if hostOwnsPortalNow { + hostedView.setDropZoneOverlay(zone: forwardedDropZone) + } coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration - if let host = nsView as? HostContainerView { + if let host = hostContainer { host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } + guard terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + inWindow: host.window != nil, + bounds: host.bounds, + reason: "didMoveToWindow" + ) else { return } guard host.window != nil else { return } TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, visibleInUI: coordinator.desiredIsVisibleInUI, - zPriority: coordinator.desiredPortalZPriority + zPriority: coordinator.desiredPortalZPriority, + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = ObjectIdentifier(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } - host.onGeometryChanged = { [weak host, weak coordinator] in - guard let host, let coordinator else { return } + host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in + guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } - guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return } - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - } - - if host.window != nil { + guard terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + inWindow: host.window != nil, + bounds: host.bounds, + reason: "geometryChanged" + ) else { return } let hostId = ObjectIdentifier(host) - let shouldBindNow = - coordinator.lastBoundHostId != hostId || - hostedView.superview == nil || - previousDesiredIsVisibleInUI != isVisibleInUI || - previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || - previousDesiredPortalZPriority != portalZPriority - if shouldBindNow { + if host.window != nil, + (coordinator.lastBoundHostId != hostId || + !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)) { +#if DEBUG + dlog( + "ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" + ) +#endif TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, visibleInUI: coordinator.desiredIsVisibleInUI, - zPriority: coordinator.desiredPortalZPriority + zPriority: coordinator.desiredPortalZPriority, + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = hostId + hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) + hostedView.setActive(coordinator.desiredIsActive) + hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } TerminalWindowPortalRegistry.synchronizeForAnchor(host) - } else { + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision + } + + if host.window != nil, hostOwnsPortalNow { + let hostId = ObjectIdentifier(host) + let geometryRevision = host.geometryRevision + let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) + let shouldBindNow = + coordinator.lastBoundHostId != hostId || + hostedView.superview == nil || + portalEntryMissing || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || + previousDesiredPortalZPriority != portalZPriority + if shouldBindNow { +#if DEBUG + if portalEntryMissing { + dlog( + "ws.hostState.rebindOnUpdate surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" + ) + } +#endif + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI, + zPriority: coordinator.desiredPortalZPriority, + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration + ) + coordinator.lastBoundHostId = hostId + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + TerminalWindowPortalRegistry.synchronizeForAnchor(host) + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } + } else if hostOwnsPortalNow { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. +#if DEBUG + if desiredStateChanged { + dlog( + "ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " + + "hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" + ) + } +#endif TerminalWindowPortalRegistry.updateEntryVisibility( for: hostedView, visibleInUI: coordinator.desiredIsVisibleInUI ) } } + + let hostWindowAttached = hostContainer?.window != nil + let isBoundToCurrentHost = hostContainer.map { host in + TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) + } ?? true + let shouldApplyImmediateHostedState = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: hostedView.superview != nil, + isBoundToCurrentHost: isBoundToCurrentHost + ) + + if shouldApplyImmediateHostedState { + hostedView.setVisibleInUI(isVisibleInUI) + hostedView.setActive(isActive) + } else { + // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. + // The currently bound host remains authoritative for immediate visible/active state. +#if DEBUG + if desiredStateChanged { + dlog( + "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=\(hostOwnsPortalNow ? "staleHostBinding" : "hostOwnershipRejected") " + + "hostWindow=\(hostWindowAttached ? 1 : 0) " + + "boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " + + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + ) + } +#endif + } } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { @@ -4569,6 +8393,10 @@ struct GhosttyTerminalView: NSViewRepresentable { if let host = nsView as? HostContainerView { host.onDidMoveToWindow = nil host.onGeometryChanged = nil + hostedView?.releaseOwnedPortalHost( + hostId: ObjectIdentifier(host), + reason: "dismantle" + ) } // SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split diff --git a/Sources/KeyboardLayout.swift b/Sources/KeyboardLayout.swift new file mode 100644 index 00000000..f7b7110a --- /dev/null +++ b/Sources/KeyboardLayout.swift @@ -0,0 +1,67 @@ +import AppKit +import Carbon + +class KeyboardLayout { + /// Return a string ID of the current keyboard input source. + static var id: String? { + if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let sourceIdPointer = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) { + let sourceId = Unmanaged<CFString>.fromOpaque(sourceIdPointer).takeUnretainedValue() + return sourceId as String + } + + return nil + } + + /// Translate a physical keyCode to the character AppKit would use for shortcut matching, + /// preserving command-aware layouts such as "Dvorak - QWERTY Command". + static func character( + forKeyCode keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags = [] + ) -> String? { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { + return nil + } + + let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self) + guard let bytes = CFDataGetBytePtr(layoutData) else { return nil } + let keyboardLayout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self) + + var deadKeyState: UInt32 = 0 + var chars = [UniChar](repeating: 0, count: 4) + var length = 0 + + let status = UCKeyTranslate( + keyboardLayout, + keyCode, + UInt16(kUCKeyActionDisplay), + translationModifierKeyState(for: modifierFlags), + UInt32(LMGetKbdType()), + UInt32(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + chars.count, + &length, + &chars + ) + + guard status == noErr, length > 0 else { return nil } + return String(utf16CodeUnits: chars, count: length).lowercased() + } + + private static func translationModifierKeyState(for modifierFlags: NSEvent.ModifierFlags) -> UInt32 { + let normalized = modifierFlags + .intersection(.deviceIndependentFlagsMask) + .intersection([.shift, .command]) + + var carbonModifiers: Int = 0 + if normalized.contains(.shift) { + carbonModifiers |= shiftKey + } + if normalized.contains(.command) { + carbonModifiers |= cmdKey + } + + return UInt32((carbonModifiers >> 8) & 0xFF) + } +} diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 8b2b8d14..f06c255b 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -8,6 +8,9 @@ enum KeyboardShortcutSettings { case toggleSidebar case newTab case newWindow + case closeWindow + case openFolder + case sendFeedback case showNotifications case jumpToUnread case triggerFlash @@ -17,7 +20,11 @@ enum KeyboardShortcutSettings { case prevSurface case nextSidebarTab case prevSidebarTab + case renameTab + case renameWorkspace + case closeWorkspace case newSurface + case toggleTerminalCopyMode // Panes / splits case focusLeft @@ -26,6 +33,7 @@ enum KeyboardShortcutSettings { case focusDown case splitRight case splitDown + case toggleSplitZoom case splitBrowserRight case splitBrowserDown @@ -38,28 +46,36 @@ enum KeyboardShortcutSettings { var label: String { switch self { - case .toggleSidebar: return "Toggle Sidebar" - case .newTab: return "New Workspace" - case .newWindow: return "New Window" - case .showNotifications: return "Show Notifications" - case .jumpToUnread: return "Jump to Latest Unread" - case .triggerFlash: return "Flash Focused Panel" - case .nextSurface: return "Next Surface" - case .prevSurface: return "Previous Surface" - case .nextSidebarTab: return "Next Workspace" - case .prevSidebarTab: return "Previous Workspace" - case .newSurface: return "New Surface" - case .focusLeft: return "Focus Pane Left" - case .focusRight: return "Focus Pane Right" - case .focusUp: return "Focus Pane Up" - case .focusDown: return "Focus Pane Down" - case .splitRight: return "Split Right" - case .splitDown: return "Split Down" - case .splitBrowserRight: return "Split Browser Right" - case .splitBrowserDown: return "Split Browser Down" - case .openBrowser: return "Open Browser" - case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools" - case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console" + case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar") + case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace") + case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window") + case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window") + case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder") + case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback") + case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications") + case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread") + case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel") + case .nextSurface: return String(localized: "shortcut.nextSurface.label", defaultValue: "Next Surface") + case .prevSurface: return String(localized: "shortcut.previousSurface.label", defaultValue: "Previous Surface") + case .nextSidebarTab: return String(localized: "shortcut.nextWorkspace.label", defaultValue: "Next Workspace") + case .prevSidebarTab: return String(localized: "shortcut.previousWorkspace.label", defaultValue: "Previous Workspace") + case .renameTab: return String(localized: "shortcut.renameTab.label", defaultValue: "Rename Tab") + case .renameWorkspace: return String(localized: "shortcut.renameWorkspace.label", defaultValue: "Rename Workspace") + case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace") + case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface") + case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode") + case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left") + case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right") + case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up") + case .focusDown: return String(localized: "shortcut.focusPaneDown.label", defaultValue: "Focus Pane Down") + case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right") + case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down") + case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom") + case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right") + case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down") + case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser") + case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools") + case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console") } } @@ -68,22 +84,30 @@ enum KeyboardShortcutSettings { case .toggleSidebar: return "shortcut.toggleSidebar" case .newTab: return "shortcut.newTab" case .newWindow: return "shortcut.newWindow" + case .closeWindow: return "shortcut.closeWindow" + case .openFolder: return "shortcut.openFolder" + case .sendFeedback: return "shortcut.sendFeedback" case .showNotifications: return "shortcut.showNotifications" case .jumpToUnread: return "shortcut.jumpToUnread" case .triggerFlash: return "shortcut.triggerFlash" case .nextSidebarTab: return "shortcut.nextSidebarTab" case .prevSidebarTab: return "shortcut.prevSidebarTab" + case .renameTab: return "shortcut.renameTab" + case .renameWorkspace: return "shortcut.renameWorkspace" + case .closeWorkspace: return "shortcut.closeWorkspace" case .focusLeft: return "shortcut.focusLeft" case .focusRight: return "shortcut.focusRight" case .focusUp: return "shortcut.focusUp" case .focusDown: return "shortcut.focusDown" case .splitRight: return "shortcut.splitRight" case .splitDown: return "shortcut.splitDown" + case .toggleSplitZoom: return "shortcut.toggleSplitZoom" case .splitBrowserRight: return "shortcut.splitBrowserRight" case .splitBrowserDown: return "shortcut.splitBrowserDown" case .nextSurface: return "shortcut.nextSurface" case .prevSurface: return "shortcut.prevSurface" case .newSurface: return "shortcut.newSurface" + case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode" case .openBrowser: return "shortcut.openBrowser" case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" @@ -98,6 +122,12 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) case .newWindow: return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false) + case .closeWindow: + return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true) + case .openFolder: + return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false) + case .sendFeedback: + return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false) case .showNotifications: return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) case .jumpToUnread: @@ -108,6 +138,12 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true) case .prevSidebarTab: return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true) + case .renameTab: + return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false) + case .renameWorkspace: + return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) + case .closeWorkspace: + return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false) case .focusLeft: return StoredShortcut(key: "←", command: true, shift: false, option: true, control: false) case .focusRight: @@ -120,6 +156,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) case .splitDown: return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false) + case .toggleSplitZoom: + return StoredShortcut(key: "\r", command: true, shift: true, option: false, control: false) case .splitBrowserRight: return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false) case .splitBrowserDown: @@ -130,6 +168,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "[", command: true, shift: true, option: false, control: false) case .newSurface: return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) + case .toggleTerminalCopyMode: + return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) case .toggleBrowserDeveloperTools: @@ -190,6 +230,8 @@ enum KeyboardShortcutSettings { static func nextSidebarTabShortcut() -> StoredShortcut { shortcut(for: .nextSidebarTab) } static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) } + static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) } + static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) } static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) } static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) } @@ -198,6 +240,7 @@ enum KeyboardShortcutSettings { static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) } static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) } + static func toggleSplitZoomShortcut() -> StoredShortcut { shortcut(for: .toggleSplitZoom) } static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) } static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) } @@ -228,6 +271,8 @@ struct StoredShortcut: Codable, Equatable { switch key { case "\t": keyText = "TAB" + case "\r": + keyText = "↩" default: keyText = key.uppercased() } @@ -244,6 +289,69 @@ struct StoredShortcut: Codable, Equatable { return flags } + var keyEquivalent: KeyEquivalent? { + switch key { + case "←": + return .leftArrow + case "→": + return .rightArrow + case "↑": + return .upArrow + case "↓": + return .downArrow + case "\t": + return .tab + case "\r": + return KeyEquivalent(Character("\r")) + default: + let lowered = key.lowercased() + guard lowered.count == 1, let character = lowered.first else { return nil } + return KeyEquivalent(character) + } + } + + var eventModifiers: EventModifiers { + var modifiers: EventModifiers = [] + if command { + modifiers.insert(.command) + } + if shift { + modifiers.insert(.shift) + } + if option { + modifiers.insert(.option) + } + if control { + modifiers.insert(.control) + } + return modifiers + } + + var menuItemKeyEquivalent: String? { + switch key { + case "←": + guard let scalar = UnicodeScalar(NSLeftArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "→": + guard let scalar = UnicodeScalar(NSRightArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "↑": + guard let scalar = UnicodeScalar(NSUpArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "↓": + guard let scalar = UnicodeScalar(NSDownArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "\t": + return "\t" + case "\r": + return "\r" + default: + let lowered = key.lowercased() + guard lowered.count == 1 else { return nil } + return lowered + } + } + static func from(event: NSEvent) -> StoredShortcut? { guard let key = storedKey(from: event) else { return nil } @@ -274,6 +382,7 @@ struct StoredShortcut: Codable, Equatable { case 125: return "↓" // down arrow case 126: return "↑" // up arrow case 48: return "\t" // tab + case 36, 76: return "\r" // return, keypad enter case 33: return "[" // kVK_ANSI_LeftBracket case 30: return "]" // kVK_ANSI_RightBracket case 27: return "-" // kVK_ANSI_Minus @@ -370,7 +479,7 @@ private class ShortcutRecorderNSButton: NSButton { func updateTitle() { if isRecording { - title = "Press shortcut…" + title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") } else { title = shortcut.displayString } diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 45e9e3f2..91f77793 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -1,3 +1,4 @@ +import Bonsplit import SwiftUI struct NotificationsPage: View { @@ -5,6 +6,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) { @@ -66,14 +68,16 @@ struct NotificationsPage: View { private var header: some View { HStack { - Text("Notifications") + Text(String(localized: "notifications.title", defaultValue: "Notifications")) .font(.title2) .fontWeight(.semibold) Spacer() if !notificationStore.notifications.isEmpty { - Button("Clear All") { + jumpToUnreadButton + + Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) { notificationStore.clearAll() } .buttonStyle(.bordered) @@ -88,20 +92,95 @@ struct NotificationsPage: View { Image(systemName: "bell.slash") .font(.system(size: 32)) .foregroundColor(.secondary) - Text("No notifications yet") + Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet")) .font(.headline) - Text("Desktop notifications will appear here for quick review.") + Text(String(localized: "notifications.empty.description", defaultValue: "Desktop notifications will appear here for quick review.")) .font(.subheadline) .foregroundColor(.secondary) } .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(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")) + ShortcutAnnotation(text: jumpToUnreadShortcut.displayString) + } + } + .buttonStyle(.bordered) + .keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers) + .safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))) + .disabled(!hasUnreadNotifications) + } else { + Button(action: { + AppDelegate.shared?.jumpToLatestUnread() + }) { + HStack(spacing: 6) { + Text(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")) + ShortcutAnnotation(text: jumpToUnreadShortcut.displayString) + } + } + .buttonStyle(.bordered) + .safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "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 } } +struct ShortcutAnnotation: View { + let text: String + var accessibilityIdentifier: String? = nil + + @ViewBuilder + var body: some View { + if let accessibilityIdentifier { + badge.accessibilityIdentifier(accessibilityIdentifier) + } else { + badge + } + } + + private var badge: 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 +193,11 @@ private struct NotificationRow: View { Button(action: onOpen) { HStack(alignment: .top, spacing: 12) { Circle() - .fill(notification.isRead ? Color.clear : Color.accentColor) + .fill(notification.isRead ? Color.clear : cmuxAccentColor()) .frame(width: 8, height: 8) .overlay( Circle() - .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + .stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) ) .padding(.top, 6) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9542325a..b96a632b 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -5,10 +5,58 @@ import AppKit import Bonsplit import SQLite3 +enum GhosttyBackgroundTheme { + static func clampedOpacity(_ opacity: Double) -> CGFloat { + CGFloat(max(0.0, min(1.0, opacity))) + } + + static func color(backgroundColor: NSColor, opacity: Double) -> NSColor { + backgroundColor.withAlphaComponent(clampedOpacity(opacity)) + } + + static func color( + from notification: Notification?, + fallbackColor: NSColor, + fallbackOpacity: Double + ) -> NSColor { + let userInfo = notification?.userInfo + let backgroundColor = + (userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor) + ?? fallbackColor + + let opacity: Double + if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? Double { + opacity = value + } else if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? NSNumber { + opacity = value.doubleValue + } else { + opacity = fallbackOpacity + } + + return color(backgroundColor: backgroundColor, opacity: opacity) + } + + static func color(from notification: Notification?) -> NSColor { + color( + from: notification, + fallbackColor: GhosttyApp.shared.defaultBackgroundColor, + fallbackOpacity: GhosttyApp.shared.defaultBackgroundOpacity + ) + } + + static func currentColor() -> NSColor { + color( + backgroundColor: GhosttyApp.shared.defaultBackgroundColor, + opacity: GhosttyApp.shared.defaultBackgroundOpacity + ) + } +} + enum BrowserSearchEngine: String, CaseIterable, Identifiable { case google case duckduckgo case bing + case kagi var id: String { rawValue } @@ -17,6 +65,7 @@ enum BrowserSearchEngine: String, CaseIterable, Identifiable { case .google: return "Google" case .duckduckgo: return "DuckDuckGo" case .bing: return "Bing" + case .kagi: return "Kagi" } } @@ -32,6 +81,8 @@ enum BrowserSearchEngine: String, CaseIterable, Identifiable { components = URLComponents(string: "https://duckduckgo.com/") case .bing: components = URLComponents(string: "https://www.bing.com/search") + case .kagi: + components = URLComponents(string: "https://kagi.com/search") } components?.queryItems = [ @@ -65,31 +116,62 @@ enum BrowserSearchSettings { } } -enum BrowserForcedDarkModeSettings { - static let enabledKey = "browserForcedDarkModeEnabled" - static let opacityKey = "browserForcedDarkModeOpacity" - static let defaultEnabled: Bool = false - static let defaultOpacity: Double = 45 - static let minOpacity: Double = 5 - static let maxOpacity: Double = 90 +enum BrowserThemeMode: String, CaseIterable, Identifiable { + case system + case light + case dark - static func enabled(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: enabledKey) == nil { - return defaultEnabled + var id: String { rawValue } + + var displayName: String { + switch self { + case .system: + return String(localized: "theme.system", defaultValue: "System") + case .light: + return String(localized: "theme.light", defaultValue: "Light") + case .dark: + return String(localized: "theme.dark", defaultValue: "Dark") } - return defaults.bool(forKey: enabledKey) } - static func opacity(defaults: UserDefaults = .standard) -> Double { - if defaults.object(forKey: opacityKey) == nil { - return defaultOpacity + var iconName: String { + switch self { + case .system: + return "circle.lefthalf.filled" + case .light: + return "sun.max" + case .dark: + return "moon" } - return normalizedOpacity(defaults.double(forKey: opacityKey)) + } +} + +enum BrowserThemeSettings { + static let modeKey = "browserThemeMode" + static let legacyForcedDarkModeEnabledKey = "browserForcedDarkModeEnabled" + static let defaultMode: BrowserThemeMode = .system + + static func mode(for rawValue: String?) -> BrowserThemeMode { + guard let rawValue, let mode = BrowserThemeMode(rawValue: rawValue) else { + return defaultMode + } + return mode } - static func normalizedOpacity(_ rawValue: Double) -> Double { - guard rawValue.isFinite else { return defaultOpacity } - return min(maxOpacity, max(minOpacity, rawValue)) + static func mode(defaults: UserDefaults = .standard) -> BrowserThemeMode { + let resolvedMode = mode(for: defaults.string(forKey: modeKey)) + if defaults.string(forKey: modeKey) != nil { + return resolvedMode + } + + // Migrate the legacy bool toggle only when the new mode key is unset. + if defaults.object(forKey: legacyForcedDarkModeEnabledKey) != nil { + let migratedMode: BrowserThemeMode = defaults.bool(forKey: legacyForcedDarkModeEnabledKey) ? .dark : .system + defaults.set(migratedMode.rawValue, forKey: modeKey) + return migratedMode + } + + return defaultMode } } @@ -97,8 +179,16 @@ enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true + static let openSidebarPullRequestLinksInCmuxBrowserKey = "browserOpenSidebarPullRequestLinksInCmuxBrowser" + static let defaultOpenSidebarPullRequestLinksInCmuxBrowser: Bool = true + + static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser" + static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true + static let browserHostWhitelistKey = "browserHostWhitelist" static let defaultBrowserHostWhitelist: String = "" + static let browserExternalOpenPatternsKey = "browserExternalOpenPatterns" + static let defaultBrowserExternalOpenPatterns: String = "" static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil { @@ -107,6 +197,30 @@ enum BrowserLinkOpenSettings { return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } + static func openSidebarPullRequestLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) == nil { + return defaultOpenSidebarPullRequestLinksInCmuxBrowser + } + return defaults.bool(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) + } + + static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil { + return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) + } + + // Migrate existing behavior for users who only had the link-click toggle. + if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) != nil { + return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) + } + + return defaultInterceptTerminalOpenCommandInCmuxBrowser + } + + static func initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: UserDefaults = .standard) -> Bool { + interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults) + } + static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] { let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist return raw @@ -115,6 +229,38 @@ enum BrowserLinkOpenSettings { .filter { !$0.isEmpty } } + static func externalOpenPatterns(defaults: UserDefaults = .standard) -> [String] { + let raw = defaults.string(forKey: browserExternalOpenPatternsKey) ?? defaultBrowserExternalOpenPatterns + return raw + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && !$0.hasPrefix("#") } + } + + static func shouldOpenExternally(_ url: URL, defaults: UserDefaults = .standard) -> Bool { + shouldOpenExternally(url.absoluteString, defaults: defaults) + } + + static func shouldOpenExternally(_ rawURL: String, defaults: UserDefaults = .standard) -> Bool { + let target = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !target.isEmpty else { return false } + + for rawPattern in externalOpenPatterns(defaults: defaults) { + guard let (isRegex, value) = parseExternalPattern(rawPattern) else { continue } + if isRegex { + guard let regex = try? NSRegularExpression(pattern: value, options: [.caseInsensitive]) else { continue } + let range = NSRange(target.startIndex..<target.endIndex, in: target) + if regex.firstMatch(in: target, options: [], range: range) != nil { + return true + } + } else if target.range(of: value, options: [.caseInsensitive]) != nil { + return true + } + } + + return false + } + /// Check whether a hostname matches the configured whitelist. /// Empty whitelist means "allow all" (no filtering). /// Supports exact match and wildcard prefix (`*.example.com`). @@ -153,6 +299,19 @@ enum BrowserLinkOpenSettings { } return host == pattern } + + private static func parseExternalPattern(_ rawPattern: String) -> (isRegex: Bool, value: String)? { + let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.lowercased().hasPrefix("re:") { + let regexPattern = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !regexPattern.isEmpty else { return nil } + return (isRegex: true, value: regexPattern) + } + + return (isRegex: false, value: trimmed) + } } enum BrowserInsecureHTTPSettings { @@ -330,6 +489,45 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest { return preparedRequest } +func browserReadAccessURL(forLocalFileURL fileURL: URL, fileManager: FileManager = .default) -> URL? { + guard fileURL.isFileURL, fileURL.path.hasPrefix("/") else { return nil } + let path = fileURL.path + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue { + return fileURL + } + + let parent = fileURL.deletingLastPathComponent() + guard !parent.path.isEmpty, parent.path.hasPrefix("/") else { return nil } + return parent +} + +@discardableResult +func browserLoadRequest(_ request: URLRequest, in webView: WKWebView) -> WKNavigation? { + guard let url = request.url else { return nil } + if url.isFileURL { + guard let readAccessURL = browserReadAccessURL(forLocalFileURL: url) else { return nil } + return webView.loadFileURL(url, allowingReadAccessTo: readAccessURL) + } + return webView.load(browserPreparedNavigationRequest(request)) +} + +private let browserEmbeddedNavigationSchemes: Set<String> = [ + "about", + "applewebdata", + "blob", + "data", + "file", + "http", + "https", + "javascript", +] + +func browserShouldOpenURLExternally(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false } + return !browserEmbeddedNavigationSchemes.contains(scheme) +} + enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve @@ -1049,6 +1247,12 @@ actor BrowserSearchSuggestionService { URLQueryItem(name: "query", value: query), ] url = c?.url + case .kagi: + var c = URLComponents(string: "https://kagi.com/api/autosuggest") + c?.queryItems = [ + URLQueryItem(name: "q", value: query), + ] + url = c?.url } guard let url else { return [] } @@ -1073,7 +1277,7 @@ actor BrowserSearchSuggestionService { } switch engine { - case .google, .bing: + case .google, .bing, .kagi: return parseOSJSON(data: data) case .duckduckgo: return parseDuckDuckGo(data: data) @@ -1123,11 +1327,127 @@ private enum BrowserInsecureHTTPNavigationIntent { case newTab } +/// Observable state for browser find-in-page. Mirrors `TerminalSurface.SearchState`. +@MainActor +final class BrowserSearchState: ObservableObject { + @Published var needle: String + @Published var selected: UInt? + @Published var total: UInt? + + init(needle: String = "") { + self.needle = needle + } +} + +final class BrowserPortalAnchorView: NSView { + override var acceptsFirstResponder: Bool { false } + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + @MainActor final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() + static let telemetryHookBootstrapScriptSource = """ + (() => { + if (window.__cmuxHooksInstalled) return true; + window.__cmuxHooksInstalled = true; + + window.__cmuxConsoleLog = window.__cmuxConsoleLog || []; + const __pushConsole = (level, args) => { + try { + const text = Array.from(args || []).map((x) => { + if (typeof x === 'string') return x; + try { return JSON.stringify(x); } catch (_) { return String(x); } + }).join(' '); + window.__cmuxConsoleLog.push({ level, text, timestamp_ms: Date.now() }); + if (window.__cmuxConsoleLog.length > 512) { + window.__cmuxConsoleLog.splice(0, window.__cmuxConsoleLog.length - 512); + } + } catch (_) {} + }; + + const methods = ['log', 'info', 'warn', 'error', 'debug']; + for (const m of methods) { + const orig = (window.console && window.console[m]) ? window.console[m].bind(window.console) : null; + window.console[m] = function(...args) { + __pushConsole(m, args); + if (orig) return orig(...args); + }; + } + + window.__cmuxErrorLog = window.__cmuxErrorLog || []; + window.addEventListener('error', (ev) => { + try { + const message = String((ev && ev.message) || ''); + const source = String((ev && ev.filename) || ''); + const line = Number((ev && ev.lineno) || 0); + const col = Number((ev && ev.colno) || 0); + window.__cmuxErrorLog.push({ message, source, line, column: col, timestamp_ms: Date.now() }); + if (window.__cmuxErrorLog.length > 512) { + window.__cmuxErrorLog.splice(0, window.__cmuxErrorLog.length - 512); + } + } catch (_) {} + }); + window.addEventListener('unhandledrejection', (ev) => { + try { + const reason = ev && ev.reason; + const message = typeof reason === 'string' ? reason : (reason && reason.message ? String(reason.message) : String(reason)); + window.__cmuxErrorLog.push({ message, source: 'unhandledrejection', line: 0, column: 0, timestamp_ms: Date.now() }); + if (window.__cmuxErrorLog.length > 512) { + window.__cmuxErrorLog.splice(0, window.__cmuxErrorLog.length - 512); + } + } catch (_) {} + }); + + return true; + })() + """ + + static let dialogTelemetryHookBootstrapScriptSource = """ + (() => { + if (window.__cmuxDialogHooksInstalled) return true; + window.__cmuxDialogHooksInstalled = true; + + window.__cmuxDialogQueue = window.__cmuxDialogQueue || []; + window.__cmuxDialogDefaults = window.__cmuxDialogDefaults || { confirm: false, prompt: null }; + const __pushDialog = (type, message, defaultText) => { + window.__cmuxDialogQueue.push({ + type, + message: String(message || ''), + default_text: defaultText == null ? null : String(defaultText), + timestamp_ms: Date.now() + }); + if (window.__cmuxDialogQueue.length > 128) { + window.__cmuxDialogQueue.splice(0, window.__cmuxDialogQueue.length - 128); + } + }; + + window.alert = function(message) { + __pushDialog('alert', message, null); + }; + window.confirm = function(message) { + __pushDialog('confirm', message, null); + return !!window.__cmuxDialogDefaults.confirm; + }; + window.prompt = function(message, defaultValue) { + __pushDialog('prompt', message, defaultValue == null ? null : defaultValue); + const v = window.__cmuxDialogDefaults.prompt; + if (v === null || v === undefined) { + return defaultValue == null ? '' : String(defaultValue); + } + return String(v); + }; + + return true; + })() + """ + private static func clampedGhosttyBackgroundOpacity(_ opacity: Double) -> CGFloat { CGFloat(max(0.0, min(1.0, opacity))) } @@ -1173,7 +1493,11 @@ final class BrowserPanel: Panel, ObservableObject { private(set) var workspaceId: UUID /// The underlying web view - let webView: WKWebView + private(set) var webView: WKWebView + + /// Monotonic identity for the current WKWebView instance. + /// Incremented whenever we replace the underlying WKWebView after a process crash. + @Published private(set) var webViewInstanceID: UUID = UUID() /// Prevent the omnibar from auto-focusing for a short window after explicit programmatic focus. /// This avoids races where SwiftUI focus state steals first responder back from WebKit. @@ -1183,7 +1507,230 @@ final class BrowserPanel: Panel, ObservableObject { /// Used to keep omnibar text-field focus from being immediately stolen by panel focus. private var suppressWebViewFocusUntil: Date? private var suppressWebViewFocusForAddressBar: Bool = false + private var addressBarFocusRestoreGeneration: UInt64 = 0 private let blankURLString = "about:blank" + private static let addressBarFocusCaptureScript = """ + (() => { + try { + const syncState = (state) => { + window.__cmuxAddressBarFocusState = state; + try { + if (window.top && window.top !== window) { + window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); + } else if (window.top) { + window.top.__cmuxAddressBarFocusState = state; + } + } catch (_) {} + }; + + const active = document.activeElement; + if (!active) { + syncState(null); + return "cleared:none"; + } + + const tag = (active.tagName || "").toLowerCase(); + const type = (active.type || "").toLowerCase(); + const isEditable = + !!active.isContentEditable || + tag === "textarea" || + (tag === "input" && type !== "hidden"); + if (!isEditable) { + syncState(null); + return "cleared:noneditable"; + } + + let id = active.getAttribute("data-cmux-addressbar-focus-id"); + if (!id) { + id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); + active.setAttribute("data-cmux-addressbar-focus-id", id); + } + + const state = { id, selectionStart: null, selectionEnd: null }; + if (typeof active.selectionStart === "number" && typeof active.selectionEnd === "number") { + state.selectionStart = active.selectionStart; + state.selectionEnd = active.selectionEnd; + } + syncState(state); + return "captured:" + id; + } catch (_) { + return "error"; + } + })(); + """ + private static let addressBarFocusTrackingBootstrapScript = """ + (() => { + try { + if (window.__cmuxAddressBarFocusTrackerInstalled) return true; + window.__cmuxAddressBarFocusTrackerInstalled = true; + + const syncState = (state) => { + window.__cmuxAddressBarFocusState = state; + try { + if (window.top && window.top !== window) { + window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); + } else if (window.top) { + window.top.__cmuxAddressBarFocusState = state; + } + } catch (_) {} + }; + + if (window.top === window && !window.__cmuxAddressBarFocusMessageBridgeInstalled) { + window.__cmuxAddressBarFocusMessageBridgeInstalled = true; + window.addEventListener("message", (ev) => { + try { + const data = ev ? ev.data : null; + if (!data || !Object.prototype.hasOwnProperty.call(data, "cmuxAddressBarFocusState")) return; + window.__cmuxAddressBarFocusState = data.cmuxAddressBarFocusState || null; + } catch (_) {} + }, true); + } + + const isEditable = (el) => { + if (!el) return false; + const tag = (el.tagName || "").toLowerCase(); + const type = (el.type || "").toLowerCase(); + return !!el.isContentEditable || tag === "textarea" || (tag === "input" && type !== "hidden"); + }; + + const ensureFocusId = (el) => { + let id = el.getAttribute("data-cmux-addressbar-focus-id"); + if (!id) { + id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); + el.setAttribute("data-cmux-addressbar-focus-id", id); + } + return id; + }; + + const snapshot = (el) => { + if (!isEditable(el)) { + syncState(null); + return; + } + const state = { + id: ensureFocusId(el), + selectionStart: null, + selectionEnd: null + }; + if (typeof el.selectionStart === "number" && typeof el.selectionEnd === "number") { + state.selectionStart = el.selectionStart; + state.selectionEnd = el.selectionEnd; + } + syncState(state); + }; + + document.addEventListener("focusin", (ev) => { + snapshot(ev && ev.target ? ev.target : document.activeElement); + }, true); + document.addEventListener("selectionchange", () => { + snapshot(document.activeElement); + }, true); + document.addEventListener("input", () => { + snapshot(document.activeElement); + }, true); + document.addEventListener("mousedown", (ev) => { + const target = ev && ev.target ? ev.target : null; + if (!isEditable(target)) { + syncState(null); + } + }, true); + window.addEventListener("beforeunload", () => { + syncState(null); + }, true); + + snapshot(document.activeElement); + return true; + } catch (_) { + return false; + } + })(); + """ + private static let addressBarFocusRestoreScript = """ + (() => { + try { + const readState = () => { + let state = window.__cmuxAddressBarFocusState; + try { + if ((!state || typeof state.id !== "string" || !state.id) && + window.top && window.top.__cmuxAddressBarFocusState) { + state = window.top.__cmuxAddressBarFocusState; + } + } catch (_) {} + return state; + }; + + const clearState = () => { + window.__cmuxAddressBarFocusState = null; + try { + if (window.top && window.top !== window) { + window.top.postMessage({ cmuxAddressBarFocusState: null }, "*"); + } else if (window.top) { + window.top.__cmuxAddressBarFocusState = null; + } + } catch (_) {} + }; + + const state = readState(); + if (!state || typeof state.id !== "string" || !state.id) { + return "no_state"; + } + + const selector = '[data-cmux-addressbar-focus-id="' + state.id + '"]'; + const findTarget = (doc) => { + if (!doc) return null; + const direct = doc.querySelector(selector); + if (direct && direct.isConnected) return direct; + const frames = doc.querySelectorAll("iframe,frame"); + for (let i = 0; i < frames.length; i += 1) { + const frame = frames[i]; + try { + const childDoc = frame.contentDocument; + if (!childDoc) continue; + const nested = findTarget(childDoc); + if (nested) return nested; + } catch (_) {} + } + return null; + }; + + const target = findTarget(document); + if (!target) { + clearState(); + return "missing_target"; + } + + try { + target.focus({ preventScroll: true }); + } catch (_) { + try { target.focus(); } catch (_) {} + } + + let focused = false; + try { + focused = + target === target.ownerDocument.activeElement || + (typeof target.matches === "function" && target.matches(":focus")); + } catch (_) {} + if (!focused) { + return "not_focused"; + } + + if ( + typeof state.selectionStart === "number" && + typeof state.selectionEnd === "number" && + typeof target.setSelectionRange === "function" + ) { + try { + target.setSelectionRange(state.selectionStart, state.selectionEnd); + } catch (_) {} + } + clearState(); + return "restored"; + } catch (_) { + return "error"; + } + })(); + """ /// Published URL being displayed @Published private(set) var currentURL: URL? @@ -1192,6 +1739,11 @@ final class BrowserPanel: Panel, ObservableObject { /// New browser tabs stay in an empty "new tab" state until first navigation. @Published private(set) var shouldRenderWebView: Bool = false + /// True when the browser is showing the internal empty new-tab page (no WKWebView attached yet). + var isShowingNewTabPage: Bool { + !shouldRenderWebView + } + /// Published page title @Published private(set) var pageTitle: String = "" @@ -1210,6 +1762,13 @@ final class BrowserPanel: Panel, ObservableObject { /// Published can go forward state @Published private(set) var canGoForward: Bool = false + private var nativeCanGoBack: Bool = false + private var nativeCanGoForward: Bool = false + private var usesRestoredSessionHistory: Bool = false + private var restoredBackHistoryStack: [URL] = [] + private var restoredForwardHistoryStack: [URL] = [] + private var restoredHistoryCurrentURL: URL? + /// Published estimated progress (0.0 - 1.0) @Published private(set) var estimatedProgress: Double = 0.0 @@ -1220,7 +1779,66 @@ final class BrowserPanel: Panel, ObservableObject { /// cleared only after BrowserPanelView acknowledges handling it. @Published private(set) var pendingAddressBarFocusRequestId: UUID? - private var cancellables = Set<AnyCancellable>() + /// Semantic in-panel focus target used by split switching and transient overlays. + private(set) var preferredFocusIntent: BrowserPanelFocusIntent = .webView + + /// Incremented whenever async browser find focus ownership changes. + @Published private(set) var searchFocusRequestGeneration: UInt64 = 0 + + /// Find-in-page state. Non-nil when the find bar is visible. + @Published var searchState: BrowserSearchState? = nil { + didSet { + if let searchState { + preferredFocusIntent = .findField + NSLog("Find: browser search state created panel=%@", id.uuidString) + searchNeedleCancellable = searchState.$needle + .removeDuplicates() + .map { needle -> AnyPublisher<String, Never> in + if needle.isEmpty || needle.count >= 3 { + return Just(needle).eraseToAnyPublisher() + } + return Just(needle) + .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + .switchToLatest() + .sink { [weak self] needle in + guard let self else { return } + NSLog("Find: browser needle updated panel=%@ needle=%@", self.id.uuidString, needle) + self.executeFindSearch(needle) + } + } else if oldValue != nil { + searchNeedleCancellable = nil + if preferredFocusIntent == .findField { + preferredFocusIntent = .webView + } + invalidateSearchFocusRequests(reason: "searchStateCleared") + NSLog("Find: browser search state cleared panel=%@", id.uuidString) + executeFindClear() + } + } + } + private var searchNeedleCancellable: AnyCancellable? + let portalAnchorView = BrowserPortalAnchorView(frame: .zero) + private struct PortalHostLease { + let hostId: ObjectIdentifier + let paneId: UUID + let inWindow: Bool + let area: CGFloat + } + private struct PortalHostLock { + let hostId: ObjectIdentifier + let paneId: UUID + } + private enum DeveloperToolsPresentation { + case unknown + case attached + case detached + } + private var activePortalHostLease: PortalHostLease? + private var pendingDistinctPortalHostReplacementPaneId: UUID? + private var lockedPortalHost: PortalHostLock? + private var webViewCancellables = Set<AnyCancellable>() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? private var downloadDelegate: BrowserDownloadDelegate? @@ -1240,15 +1858,26 @@ final class BrowserPanel: Panel, ObservableObject { private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 private var insecureHTTPBypassHostOnce: String? + private var insecureHTTPAlertFactory: () -> NSAlert + private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } // Persist user intent across WebKit detach/reattach churn (split/layout updates). - private var preferredDeveloperToolsVisible: Bool = false + @Published private(set) var preferredDeveloperToolsVisible: Bool = false + private var preferredDeveloperToolsPresentation: DeveloperToolsPresentation = .unknown private var forceDeveloperToolsRefreshOnNextAttach: Bool = false private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 private let developerToolsRestoreRetryMaxAttempts: Int = 40 - private var forcedDarkModeEnabled: Bool - private var forcedDarkModeOpacity: Double + private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 + private var developerToolsDetachedOpenGraceDeadline: Date? + private var developerToolsTransitionTargetVisible: Bool? + private var pendingDeveloperToolsTransitionTargetVisible: Bool? + private var developerToolsTransitionSettleWorkItem: DispatchWorkItem? + private let developerToolsTransitionSettleDelay: TimeInterval = 0.15 + private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol? + private var preferredAttachedDeveloperToolsWidth: CGFloat? + private var preferredAttachedDeveloperToolsWidthFraction: CGFloat? + private var browserThemeMode: BrowserThemeMode var displayTitle: String { if !pageTitle.isEmpty { @@ -1257,7 +1886,157 @@ final class BrowserPanel: Panel, ObservableObject { if let url = currentURL { return url.host ?? url.absoluteString } - return "New tab" + return String(localized: "browser.newTab", defaultValue: "New tab") + } + + private static let portalHostAreaThreshold: CGFloat = 4 + private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2 + + private static func portalHostArea(for bounds: CGRect) -> CGFloat { + max(0, bounds.width) * max(0, bounds.height) + } + + private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool { + lease.inWindow && lease.area > portalHostAreaThreshold + } + + func preparePortalHostReplacementForNextDistinctClaim( + inPane paneId: PaneID, + reason: String + ) { + pendingDistinctPortalHostReplacementPaneId = paneId.id + if lockedPortalHost?.paneId == paneId.id { + lockedPortalHost = nil + } +#if DEBUG + dlog( + "browser.portal.host.rearm panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) pane=\(paneId.id.uuidString.prefix(5))" + ) +#endif + } + + func claimPortalHost( + hostId: ObjectIdentifier, + paneId: PaneID, + inWindow: Bool, + bounds: CGRect, + reason: String + ) -> Bool { + let next = PortalHostLease( + hostId: hostId, + paneId: paneId.id, + inWindow: inWindow, + area: Self.portalHostArea(for: bounds) + ) + + if let current = activePortalHostLease { + if let lock = lockedPortalHost, + (lock.hostId != current.hostId || lock.paneId != current.paneId) { + lockedPortalHost = nil + } + + if current.hostId == hostId { + activePortalHostLease = next + return true + } + + let currentUsable = Self.portalHostIsUsable(current) + let nextUsable = Self.portalHostIsUsable(next) + let isSamePaneReplacement = current.paneId == paneId.id + let shouldForceDistinctReplacement = + isSamePaneReplacement && + pendingDistinctPortalHostReplacementPaneId == paneId.id && + inWindow + if shouldForceDistinctReplacement { +#if DEBUG + dlog( + "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " + + "replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area)) " + + "forced=1" + ) +#endif + activePortalHostLease = next + pendingDistinctPortalHostReplacementPaneId = nil + lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id) + return true + } + + let lockBlocksSamePaneReplacement = + isSamePaneReplacement && + currentUsable && + lockedPortalHost?.hostId == current.hostId && + lockedPortalHost?.paneId == current.paneId + let shouldReplace = + current.paneId != paneId.id || + !currentUsable || + ( + !lockBlocksSamePaneReplacement && + nextUsable && + next.area > (current.area * Self.portalHostReplacementAreaGainRatio) + ) + + if shouldReplace { + if lockedPortalHost?.hostId == current.hostId && + lockedPortalHost?.paneId == current.paneId { + lockedPortalHost = nil + } +#if DEBUG + dlog( + "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " + + "replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area))" + ) +#endif + activePortalHostLease = next + return true + } + +#if DEBUG + dlog( + "browser.portal.host.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " + + "ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area)) " + + "locked=\(lockBlocksSamePaneReplacement ? 1 : 0)" + ) +#endif + return false + } + + activePortalHostLease = next +#if DEBUG + dlog( + "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "replacingHost=nil" + ) +#endif + return true + } + + @discardableResult + func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool { + guard let current = activePortalHostLease, current.hostId == hostId else { return false } + activePortalHostLease = nil + if lockedPortalHost?.hostId == hostId { + lockedPortalHost = nil + } +#if DEBUG + dlog( + "browser.portal.host.release panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " + + "inWin=\(current.inWindow ? 1 : 0) area=\(String(format: "%.1f", current.area))" + ) +#endif + return true } var displayIcon: String? { @@ -1268,16 +2047,10 @@ final class BrowserPanel: Panel, ObservableObject { false } - init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { - self.id = UUID() - self.workspaceId = workspaceId - self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") - self.forcedDarkModeEnabled = BrowserForcedDarkModeSettings.enabled() - self.forcedDarkModeOpacity = BrowserForcedDarkModeSettings.opacity() - - // Configure web view + private static func makeWebView() -> CmuxWebView { let config = WKWebViewConfiguration() config.processPool = BrowserPanel.sharedProcessPool + config.mediaTypesRequiringUserActionForPlayback = [] // Ensure browser cookies/storage persist across navigations and launches. // This reduces repeated consent/bot-challenge flows on sites like Google. config.websiteDataStore = .default() @@ -1287,42 +2060,88 @@ final class BrowserPanel: Panel, ObservableObject { // Enable JavaScript config.defaultWebpagePreferences.allowsContentJavaScript = true + // Keep browser console/error/dialog telemetry active from document start on every navigation. + config.userContentController.addUserScript( + WKUserScript( + source: Self.telemetryHookBootstrapScriptSource, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) + // Track the last editable focused element continuously so omnibar exit can + // restore page input focus even if capture runs after first-responder handoff. + config.userContentController.addUserScript( + WKUserScript( + source: Self.addressBarFocusTrackingBootstrapScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) - // Set up web view let webView = CmuxWebView(frame: .zero, configuration: config) webView.allowsBackForwardNavigationGestures = true - - // Required for Web Inspector support on recent WebKit SDKs. if #available(macOS 13.3, *) { webView.isInspectable = true } - // Match the empty-page background to the terminal theme so newly-created browsers // don't flash white before content loads. - webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor() - + webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor() // Always present as Safari. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent + return webView + } + private func bindWebView(_ webView: CmuxWebView) { + webView.onContextMenuDownloadStateChanged = { [weak self] downloading in + if downloading { + self?.beginDownloadActivity() + } else { + self?.endDownloadActivity() + } + } + webView.navigationDelegate = navigationDelegate + webView.uiDelegate = uiDelegate + setupObservers(for: webView) + } + + private func isCurrentWebView(_ candidate: WKWebView, instanceID: UUID? = nil) -> Bool { + guard candidate === webView else { return false } + guard let instanceID else { return true } + return instanceID == webViewInstanceID + } + + init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { + self.id = UUID() + self.workspaceId = workspaceId + self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") + self.browserThemeMode = BrowserThemeSettings.mode() + + let webView = Self.makeWebView() self.webView = webView + self.insecureHTTPAlertFactory = { NSAlert() } // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() navDelegate.didFinish = { webView in BrowserHistoryStore.shared.recordVisit(url: webView.url, title: webView.title) Task { @MainActor [weak self] in - self?.refreshFavicon(from: webView) - self?.applyForcedDarkModeIfNeeded() + guard let self, self.isCurrentWebView(webView) else { return } + self.refreshFavicon(from: webView) + self.applyBrowserThemeModeIfNeeded() + // Keep find-in-page open through load completion and refresh matches for the new DOM. + self.restoreFindStateAfterNavigation(replaySearch: true) } } - navDelegate.didFailNavigation = { [weak self] _, failedURL in + navDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in Task { @MainActor in - guard let self else { return } + guard let self, self.isCurrentWebView(failedWebView) else { return } // Clear stale title/favicon from the previous page so the tab // shows the failed URL instead of the old page's branding. self.pageTitle = failedURL.isEmpty ? "" : failedURL self.faviconPNGData = nil self.lastFaviconURLString = nil + // Keep find-in-page open and clear stale counters on failed loads. + self.restoreFindStateAfterNavigation(replaySearch: false) } } navDelegate.openInNewTab = { [weak self] url in @@ -1334,6 +2153,9 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] request, intent in self?.presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false) } + navDelegate.didTerminateWebContentProcess = { [weak self] webView in + self?.replaceWebViewAfterContentProcessTermination(for: webView) + } // Set up download delegate for navigation-based downloads. // Downloads save to a temp file synchronously (no NSSavePanel during WebKit // callbacks), then show NSSavePanel after the download completes. @@ -1349,14 +2171,6 @@ final class BrowserPanel: Panel, ObservableObject { } navDelegate.downloadDelegate = dlDelegate self.downloadDelegate = dlDelegate - webView.onContextMenuDownloadStateChanged = { [weak self] downloading in - if downloading { - self?.beginDownloadActivity() - } else { - self?.endDownloadActivity() - } - } - webView.navigationDelegate = navDelegate self.navigationDelegate = navDelegate // Set up UI delegate (handles cmd+click, target=_blank, and context menu) @@ -1368,12 +2182,14 @@ final class BrowserPanel: Panel, ObservableObject { browserUIDelegate.requestNavigation = { [weak self] request, intent in self?.requestNavigation(request, intent: intent) } - webView.uiDelegate = browserUIDelegate self.uiDelegate = browserUIDelegate - // Observe web view properties - setupObservers() - applyForcedDarkModeIfNeeded() + bindWebView(webView) + installDetachedDeveloperToolsWindowCloseObserver() + applyBrowserThemeModeIfNeeded() + insecureHTTPAlertWindowProvider = { [weak self] in + self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } // Navigate to initial URL if provided if let url = initialURL { @@ -1414,11 +2230,51 @@ final class BrowserPanel: Panel, ObservableObject { focusFlashToken &+= 1 } - private func setupObservers() { + func sessionNavigationHistorySnapshot() -> ( + backHistoryURLStrings: [String], + forwardHistoryURLStrings: [String] + ) { + if usesRestoredSessionHistory { + let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) } + // `restoredForwardHistoryStack` stores nearest-forward entries at the end. + let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) } + return (back, forward) + } + + let back = webView.backForwardList.backList.compactMap { + Self.serializableSessionHistoryURLString($0.url) + } + let forward = webView.backForwardList.forwardList.compactMap { + Self.serializableSessionHistoryURLString($0.url) + } + return (back, forward) + } + + func restoreSessionNavigationHistory( + backHistoryURLStrings: [String], + forwardHistoryURLStrings: [String], + currentURLString: String? + ) { + let restoredBack = Self.sanitizedSessionHistoryURLs(backHistoryURLStrings) + let restoredForward = Self.sanitizedSessionHistoryURLs(forwardHistoryURLStrings) + guard !restoredBack.isEmpty || !restoredForward.isEmpty else { return } + + usesRestoredSessionHistory = true + restoredBackHistoryStack = restoredBack + // Store nearest-forward entries at the end to make stack pop operations trivial. + restoredForwardHistoryStack = Array(restoredForward.reversed()) + restoredHistoryCurrentURL = Self.sanitizedSessionHistoryURL(currentURLString) + refreshNavigationAvailability() + } + + private func setupObservers(for webView: WKWebView) { + let observedWebViewInstanceID = webViewInstanceID + // URL changes let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.currentURL = webView.url + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } + self.currentURL = webView.url } } webViewObservers.append(urlObserver) @@ -1426,12 +2282,13 @@ final class BrowserPanel: Panel, ObservableObject { // Title changes let titleObserver = webView.observe(\.title, options: [.new]) { [weak self] webView, _ in Task { @MainActor in + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } // Keep showing the last non-empty title while the new navigation is loading. // WebKit often clears title to nil/"" during reload/navigation, which causes // a distracting tab-title flash (e.g. to host/URL). Only accept non-empty titles. let trimmed = (webView.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - self?.pageTitle = trimmed + self.pageTitle = trimmed } } webViewObservers.append(titleObserver) @@ -1439,7 +2296,8 @@ final class BrowserPanel: Panel, ObservableObject { // Loading state let loadingObserver = webView.observe(\.isLoading, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.handleWebViewLoadingChanged(webView.isLoading) + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } + self.handleWebViewLoadingChanged(webView.isLoading) } } webViewObservers.append(loadingObserver) @@ -1447,7 +2305,9 @@ final class BrowserPanel: Panel, ObservableObject { // Can go back let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.canGoBack = webView.canGoBack + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } + self.nativeCanGoBack = webView.canGoBack + self.refreshNavigationAvailability() } } webViewObservers.append(backObserver) @@ -1455,7 +2315,9 @@ final class BrowserPanel: Panel, ObservableObject { // Can go forward let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.canGoForward = webView.canGoForward + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } + self.nativeCanGoForward = webView.canGoForward + self.refreshNavigationAvailability() } } webViewObservers.append(forwardObserver) @@ -1463,7 +2325,8 @@ final class BrowserPanel: Panel, ObservableObject { // Progress let progressObserver = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.estimatedProgress = webView.estimatedProgress + guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } + self.estimatedProgress = webView.estimatedProgress } } webViewObservers.append(progressObserver) @@ -1471,11 +2334,91 @@ final class BrowserPanel: Panel, ObservableObject { NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange) .sink { [weak self] notification in guard let self else { return } - self.webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor(from: notification) + self.webView.underPageBackgroundColor = GhosttyBackgroundTheme.color(from: notification) } - .store(in: &cancellables) + .store(in: &webViewCancellables) } + private func replaceWebViewAfterContentProcessTermination(for terminatedWebView: WKWebView) { + guard terminatedWebView === webView else { return } + + let wasRenderable = shouldRenderWebView + let restoreURL = terminatedWebView.url ?? currentURL + let restoreURLString = restoreURL?.absoluteString + let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString + let history = sessionNavigationHistorySnapshot() + let historyCurrentURL = preferredURLStringForOmnibar() + let desiredZoom = max(minPageZoom, min(maxPageZoom, terminatedWebView.pageZoom)) + let restoreDevTools = preferredDeveloperToolsVisible + +#if DEBUG + dlog( + "browser.webview.replace.begin panel=\(id.uuidString.prefix(5)) " + + "renderable=\(wasRenderable ? 1 : 0) restoreURL=\(restoreURLString ?? "nil") " + + "restoreHistoryBack=\(history.backHistoryURLStrings.count) " + + "restoreHistoryForward=\(history.forwardHistoryURLStrings.count)" + ) +#endif + + webViewObservers.removeAll() + webViewCancellables.removeAll() + faviconTask?.cancel() + faviconTask = nil + faviconRefreshGeneration &+= 1 + BrowserWindowPortalRegistry.detach(webView: terminatedWebView) + terminatedWebView.stopLoading() + terminatedWebView.navigationDelegate = nil + terminatedWebView.uiDelegate = nil + if let terminatedCmuxWebView = terminatedWebView as? CmuxWebView { + terminatedCmuxWebView.onContextMenuDownloadStateChanged = nil + } + + let replacement = Self.makeWebView() + replacement.pageZoom = desiredZoom + webViewInstanceID = UUID() + webView = replacement + shouldRenderWebView = wasRenderable + + bindWebView(replacement) + applyBrowserThemeModeIfNeeded() + + if !history.backHistoryURLStrings.isEmpty || !history.forwardHistoryURLStrings.isEmpty { + restoreSessionNavigationHistory( + backHistoryURLStrings: history.backHistoryURLStrings, + forwardHistoryURLStrings: history.forwardHistoryURLStrings, + currentURLString: historyCurrentURL + ) + } + + if shouldRestoreURL, let restoreURL { + navigateWithoutInsecureHTTPPrompt( + to: restoreURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + } else { + refreshNavigationAvailability() + } + + if restoreDevTools { + requestDeveloperToolsRefreshAfterNextAttach(reason: "webcontent_process_terminated") + } + +#if DEBUG + dlog( + "browser.webview.replace.end panel=\(id.uuidString.prefix(5)) " + + "instance=\(webViewInstanceID.uuidString.prefix(6)) " + + "restoreURL=\(restoreURLString ?? "nil") shouldRestore=\(shouldRestoreURL ? 1 : 0)" + ) +#endif + } + +#if DEBUG + func debugSimulateWebContentProcessTermination() { + replaceWebViewAfterContentProcessTermination(for: webView) + } +#endif + // MARK: - Panel Protocol func focus() { @@ -1494,12 +2437,16 @@ final class BrowserPanel: Panel, ObservableObject { } if Self.responderChainContains(window.firstResponder, target: webView) { + noteWebViewFocused() return } - window.makeFirstResponder(webView) + if window.makeFirstResponder(webView) { + noteWebViewFocused() + } } func unfocus() { + invalidateSearchFocusRequests(reason: "panelUnfocus") guard let window = webView.window else { return } if Self.responderChainContains(window.firstResponder, target: webView) { window.makeFirstResponder(nil) @@ -1516,7 +2463,7 @@ final class BrowserPanel: Panel, ObservableObject { navigationDelegate = nil uiDelegate = nil webViewObservers.removeAll() - cancellables.removeAll() + webViewCancellables.removeAll() faviconTask?.cancel() faviconTask = nil } @@ -1529,9 +2476,11 @@ final class BrowserPanel: Panel, ObservableObject { guard let scheme = pageURL.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return } faviconRefreshGeneration &+= 1 let refreshGeneration = faviconRefreshGeneration + let refreshWebViewInstanceID = webViewInstanceID faviconTask = Task { @MainActor [weak self, weak webView] in guard let self, let webView else { return } + guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } // Try to discover the best icon URL from the document. @@ -1566,6 +2515,7 @@ final class BrowserPanel: Panel, ObservableObject { discoveredURL = u } } + guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } let fallbackURL = URL(string: "/favicon.ico", relativeTo: pageURL) @@ -1591,6 +2541,7 @@ final class BrowserPanel: Panel, ObservableObject { } catch { return } + guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } guard let http = response as? HTTPURLResponse, @@ -1673,6 +2624,9 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask?.cancel() faviconTask = nil lastFaviconURLString = nil + // Clear the previous page's favicon so it never persists across navigations. + // The loading spinner covers this gap; didFinish will fetch the new favicon. + faviconPNGData = nil loadingGeneration &+= 1 loadingEndWorkItem?.cancel() loadingEndWorkItem = nil @@ -1718,13 +2672,28 @@ final class BrowserPanel: Panel, ObservableObject { navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) } - private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) { + private func navigateWithoutInsecureHTTPPrompt( + to url: URL, + recordTypedNavigation: Bool, + preserveRestoredSessionHistory: Bool = false + ) { let request = URLRequest(url: url) - navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) + navigateWithoutInsecureHTTPPrompt( + request: request, + recordTypedNavigation: recordTypedNavigation, + preserveRestoredSessionHistory: preserveRestoredSessionHistory + ) } - private func navigateWithoutInsecureHTTPPrompt(request: URLRequest, recordTypedNavigation: Bool) { + private func navigateWithoutInsecureHTTPPrompt( + request: URLRequest, + recordTypedNavigation: Bool, + preserveRestoredSessionHistory: Bool = false + ) { guard let url = request.url else { return } + if !preserveRestoredSessionHistory { + abandonRestoredSessionHistoryIfNeeded() + } // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true @@ -1732,7 +2701,7 @@ final class BrowserPanel: Panel, ObservableObject { BrowserHistoryStore.shared.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url - webView.load(browserPreparedNavigationRequest(request)) + browserLoadRequest(request, in: webView) } /// Navigate with smart URL/search detection @@ -1785,24 +2754,48 @@ final class BrowserPanel: Panel, ObservableObject { guard let url = request.url else { return } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } - let alert = NSAlert() + let alert = insecureHTTPAlertFactory() alert.alertStyle = .warning - alert.messageText = "Connection isn't secure" - alert.informativeText = """ - \(host) uses plain HTTP, so traffic can be read or modified on the network. - - Open this URL in your default browser, or proceed in cmux. - """ - alert.addButton(withTitle: "Open in Default Browser") - alert.addButton(withTitle: "Proceed in cmux") - alert.addButton(withTitle: "Cancel") + alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") + alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in cmux.") + alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser")) + alert.addButton(withTitle: String(localized: "browser.proceedInCmux", defaultValue: "Proceed in cmux")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) alert.showsSuppressionButton = true - alert.suppressionButton?.title = "Always allow this host in cmux" + alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in cmux") - let response = alert.runModal() + let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak self, weak alert] response in + self?.handleInsecureHTTPAlertResponse( + response, + alert: alert, + host: host, + request: request, + url: url, + intent: intent, + recordTypedNavigation: recordTypedNavigation + ) + } + + if let alertWindow = insecureHTTPAlertWindowProvider() { + alert.beginSheetModal(for: alertWindow, completionHandler: handleResponse) + return + } + + handleResponse(alert.runModal()) + } + + private func handleInsecureHTTPAlertResponse( + _ response: NSApplication.ModalResponse, + alert: NSAlert?, + host: String, + request: URLRequest, + url: URL, + intent: BrowserInsecureHTTPNavigationIntent, + recordTypedNavigation: Bool + ) { if browserShouldPersistInsecureHTTPAllowlistSelection( response: response, - suppressionEnabled: alert.suppressionButton?.state == .on + suppressionEnabled: alert?.suppressionButton?.state == .on ) { BrowserInsecureHTTPSettings.addAllowedHost(host) } @@ -1825,12 +2818,126 @@ final class BrowserPanel: Panel, ObservableObject { deinit { developerToolsRestoreRetryWorkItem?.cancel() developerToolsRestoreRetryWorkItem = nil + developerToolsTransitionSettleWorkItem?.cancel() + developerToolsTransitionSettleWorkItem = nil + if let detachedDeveloperToolsWindowCloseObserver { + NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver) + } + webViewObservers.removeAll() + webViewCancellables.removeAll() let webView = webView Task { @MainActor in BrowserWindowPortalRegistry.detach(webView: webView) } + } +} + +extension BrowserPanel { + private var needsWorkspaceContextReset: Bool { + shouldRenderWebView || + currentURL != nil || + !pageTitle.isEmpty || + faviconPNGData != nil || + searchState != nil || + nativeCanGoBack || + nativeCanGoForward || + restoredHistoryCurrentURL != nil || + !restoredBackHistoryStack.isEmpty || + !restoredForwardHistoryStack.isEmpty || + estimatedProgress > 0 || + isLoading || + isDownloading || + activeDownloadCount != 0 || + preferredDeveloperToolsVisible || + webView.superview != nil + } + + func resetForWorkspaceContextChange(reason: String) { + guard needsWorkspaceContextReset else { +#if DEBUG + dlog( + "browser.contextReset.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) render=\(shouldRenderWebView ? 1 : 0)" + ) +#endif + return + } + +#if DEBUG + dlog( + "browser.contextReset.begin panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) render=\(shouldRenderWebView ? 1 : 0) " + + "url=\(preferredURLStringForOmnibar() ?? "nil")" + ) +#endif + + _ = hideDeveloperTools() + cancelDeveloperToolsRestoreRetry() + preferredDeveloperToolsVisible = false + preferredDeveloperToolsPresentation = .unknown + forceDeveloperToolsRefreshOnNextAttach = false + developerToolsDetachedOpenGraceDeadline = nil + developerToolsRestoreRetryAttempt = 0 + preferredAttachedDeveloperToolsWidth = nil + preferredAttachedDeveloperToolsWidthFraction = nil + + loadingEndWorkItem?.cancel() + loadingEndWorkItem = nil + faviconTask?.cancel() + faviconTask = nil + faviconRefreshGeneration &+= 1 + loadingGeneration &+= 1 + activeDownloadCount = 0 + isDownloading = false + isLoading = false + estimatedProgress = 0 + nativeCanGoBack = false + nativeCanGoForward = false + navigationDelegate?.lastAttemptedURL = nil + abandonRestoredSessionHistoryIfNeeded() + + pendingAddressBarFocusRequestId = nil + preferredFocusIntent = .addressBar + suppressOmnibarAutofocusUntil = nil + suppressWebViewFocusUntil = nil + endSuppressWebViewFocusForAddressBar() + invalidateAddressBarPageFocusRestoreAttempts() + invalidateSearchFocusRequests(reason: "contextReset") + searchState = nil + + pageTitle = "" + currentURL = nil + faviconPNGData = nil + lastFaviconURLString = nil + activePortalHostLease = nil + pendingDistinctPortalHostReplacementPaneId = nil + lockedPortalHost = nil + + let oldWebView = webView webViewObservers.removeAll() - cancellables.removeAll() + webViewCancellables.removeAll() + BrowserWindowPortalRegistry.detach(webView: oldWebView) + oldWebView.stopLoading() + oldWebView.navigationDelegate = nil + oldWebView.uiDelegate = nil + if let oldCmuxWebView = oldWebView as? CmuxWebView { + oldCmuxWebView.onContextMenuDownloadStateChanged = nil + } + + let replacement = Self.makeWebView() + webViewInstanceID = UUID() + webView = replacement + shouldRenderWebView = false + bindWebView(replacement) + applyBrowserThemeModeIfNeeded() + refreshNavigationAvailability() + +#if DEBUG + dlog( + "browser.contextReset.end panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) instance=\(webViewInstanceID.uuidString.prefix(6))" + ) +#endif } } @@ -1850,6 +2957,9 @@ func resolveBrowserNavigableURL(_ input: String) -> URL? { if scheme == "http" || scheme == "https" { return url } + if scheme == "file", url.isFileURL, url.path.hasPrefix("/") { + return url + } return nil } @@ -1869,26 +2979,93 @@ extension BrowserPanel { /// Go back in history func goBack() { guard canGoBack else { return } + if usesRestoredSessionHistory { + guard let targetURL = restoredBackHistoryStack.popLast() else { + refreshNavigationAvailability() + return + } + if let current = resolvedCurrentSessionHistoryURL() { + restoredForwardHistoryStack.append(current) + } + restoredHistoryCurrentURL = targetURL + refreshNavigationAvailability() + navigateWithoutInsecureHTTPPrompt( + to: targetURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + return + } + webView.goBack() } /// Go forward in history func goForward() { guard canGoForward else { return } + if usesRestoredSessionHistory { + guard let targetURL = restoredForwardHistoryStack.popLast() else { + refreshNavigationAvailability() + return + } + if let current = resolvedCurrentSessionHistoryURL() { + restoredBackHistoryStack.append(current) + } + restoredHistoryCurrentURL = targetURL + refreshNavigationAvailability() + navigateWithoutInsecureHTTPPrompt( + to: targetURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + return + } + webView.goForward() } /// Open a link in a new browser surface in the same pane func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) { - guard let tabManager = AppDelegate.shared?.tabManager, - let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }), - let paneId = workspace.paneId(forPanelId: id) else { return } +#if DEBUG + dlog( + "browser.newTab.open.begin panel=\(id.uuidString.prefix(5)) " + + "workspace=\(workspaceId.uuidString.prefix(5)) url=\(url.absoluteString) " + + "bypass=\(bypassInsecureHTTPHostOnce ?? "nil")" + ) +#endif + guard let app = AppDelegate.shared else { +#if DEBUG + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingAppDelegate") +#endif + return + } + guard let workspace = app.workspaceContainingPanel( + panelId: id, + preferredWorkspaceId: workspaceId + )?.workspace else { +#if DEBUG + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing") +#endif + return + } + guard let paneId = workspace.paneId(forPanelId: id) else { +#if DEBUG + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing") +#endif + return + } workspace.newBrowserSurface( inPane: paneId, url: url, focus: true, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) +#if DEBUG + dlog( + "browser.newTab.open.done panel=\(id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5))" + ) +#endif } /// Reload the current page @@ -1902,26 +3079,259 @@ extension BrowserPanel { webView.stopLoading() } - @discardableResult - func toggleDeveloperTools() -> Bool { + private static func windowContainsInspectorViews(_ root: NSView) -> Bool { + if String(describing: type(of: root)).contains("WKInspector") { + return true + } + for subview in root.subviews where windowContainsInspectorViews(subview) { + return true + } + return false + } + + private static func isDetachedInspectorWindow(_ window: NSWindow) -> Bool { + guard window.title.hasPrefix("Web Inspector") else { return false } + guard let contentView = window.contentView else { return false } + return windowContainsInspectorViews(contentView) + } + + private func detachedDeveloperToolsWindows() -> [NSWindow] { + let mainWindow = webView.window + return NSApp.windows.filter { candidate in + if let mainWindow, candidate === mainWindow { + return false + } + return Self.isDetachedInspectorWindow(candidate) + } + } + + private func hasAttachedDeveloperToolsLayout() -> Bool { + guard let container = webView.superview else { return false } + return Self.visibleDescendants(in: container) + .contains { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) } + } + + private func setPreferredDeveloperToolsPresentation(_ next: DeveloperToolsPresentation) { + guard preferredDeveloperToolsPresentation != next else { return } + preferredDeveloperToolsPresentation = next + DispatchQueue.main.async { [weak self] in + self?.objectWillChange.send() + } + } + + private func syncDeveloperToolsPresentationPreferenceFromUI() { + if !detachedDeveloperToolsWindows().isEmpty { + setPreferredDeveloperToolsPresentation(.detached) + } else if hasAttachedDeveloperToolsLayout() { + setPreferredDeveloperToolsPresentation(.attached) + developerToolsDetachedOpenGraceDeadline = nil + } + } + + private func installDetachedDeveloperToolsWindowCloseObserver() { + guard detachedDeveloperToolsWindowCloseObserver == nil else { return } + detachedDeveloperToolsWindowCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, + let window = notification.object as? NSWindow else { return } + let isDetachedInspectorWindow = MainActor.assumeIsolated { + Self.isDetachedInspectorWindow(window) + } + guard isDetachedInspectorWindow else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard self.preferredDeveloperToolsPresentation == .detached else { return } + guard self.preferredDeveloperToolsVisible else { return } + guard !self.isDeveloperToolsVisible() else { return } + self.developerToolsDetachedOpenGraceDeadline = nil + self.preferredDeveloperToolsVisible = false + self.cancelDeveloperToolsRestoreRetry() #if DEBUG - dlog( - "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + - "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" - ) + dlog( + "browser.devtools detachedClose.manual panel=\(self.id.uuidString.prefix(5)) " + + "\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())" + ) #endif + } + } + } + + private func shouldDismissDetachedDeveloperToolsWindows() -> Bool { + preferredDeveloperToolsPresentation == .attached + } + + private func dismissDetachedDeveloperToolsWindowsIfNeeded() { + guard shouldDismissDetachedDeveloperToolsWindows() else { return } + guard preferredDeveloperToolsVisible || isDeveloperToolsVisible(), + let mainWindow = webView.window else { return } + for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) { +#if DEBUG + dlog( + "browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " + + "title=\(window.title) frame=\(NSStringFromRect(window.frame))" + ) +#endif + window.close() + } + } + + private func scheduleDetachedDeveloperToolsWindowDismissal() { + guard shouldDismissDetachedDeveloperToolsWindows() else { return } + for delay in [0.0, 0.15] { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.dismissDetachedDeveloperToolsWindowsIfNeeded() + } + } + } + + private func prepareDeveloperToolsForRevealIfNeeded(_ inspector: NSObject) { + guard preferredDeveloperToolsPresentation == .unknown else { return } + let attachSelector = NSSelectorFromString("attach") + guard inspector.responds(to: attachSelector) else { return } + inspector.cmuxCallVoid(selector: attachSelector) + } + + @discardableResult + private func revealDeveloperTools(_ inspector: NSObject) -> Bool { + let isVisibleSelector = NSSelectorFromString("isVisible") + if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false { + developerToolsDetachedOpenGraceDeadline = nil + return true + } + + prepareDeveloperToolsForRevealIfNeeded(inspector) + + let showSelector = NSSelectorFromString("show") + guard inspector.responds(to: showSelector) else { return false } + inspector.cmuxCallVoid(selector: showSelector) + let visibleAfterShow = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false + if preferredDeveloperToolsPresentation == .detached { + developerToolsDetachedOpenGraceDeadline = visibleAfterShow + ? nil + : Date().addingTimeInterval(developerToolsDetachedOpenGracePeriod) + } else { + developerToolsDetachedOpenGraceDeadline = nil + } + return visibleAfterShow + } + + @discardableResult + private func concealDeveloperTools(_ inspector: NSObject) -> Bool { + let isVisibleSelector = NSSelectorFromString("isVisible") + guard inspector.cmuxCallBool(selector: isVisibleSelector) ?? false else { return true } + + var invokedSelector = false + for rawSelector in ["hide", "close"] { + let selector = NSSelectorFromString(rawSelector) + guard inspector.responds(to: selector) else { continue } + invokedSelector = true + inspector.cmuxCallVoid(selector: selector) + if !(inspector.cmuxCallBool(selector: isVisibleSelector) ?? false) { + return true + } + } + + guard invokedSelector else { return false } + return !(inspector.cmuxCallBool(selector: isVisibleSelector) ?? false) + } + + private var isDeveloperToolsTransitionInFlight: Bool { + developerToolsTransitionSettleWorkItem != nil + } + + private func effectiveDeveloperToolsVisibilityIntent() -> Bool { + if let pendingDeveloperToolsTransitionTargetVisible { + return pendingDeveloperToolsTransitionTargetVisible + } + if let developerToolsTransitionTargetVisible { + return developerToolsTransitionTargetVisible + } + return isDeveloperToolsVisible() + } + + private func scheduleDeveloperToolsTransitionSettle(source: String) { + developerToolsTransitionSettleWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.developerToolsTransitionSettleWorkItem = nil + self?.finishDeveloperToolsTransition(source: source) + } + developerToolsTransitionSettleWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsTransitionSettleDelay, execute: workItem) + } + + private func finishDeveloperToolsTransition(source: String) { + let pendingTargetVisible = pendingDeveloperToolsTransitionTargetVisible + pendingDeveloperToolsTransitionTargetVisible = nil + developerToolsTransitionTargetVisible = nil + + guard let pendingTargetVisible else { return } + guard pendingTargetVisible != isDeveloperToolsVisible() else { return } + _ = performDeveloperToolsVisibilityTransition(to: pendingTargetVisible, source: "\(source).queued") + } + + @discardableResult + private func enqueueDeveloperToolsVisibilityTransition( + to targetVisible: Bool, + source: String + ) -> Bool { + if isDeveloperToolsTransitionInFlight { + pendingDeveloperToolsTransitionTargetVisible = targetVisible + preferredDeveloperToolsVisible = targetVisible + if !targetVisible { + developerToolsDetachedOpenGraceDeadline = nil + forceDeveloperToolsRefreshOnNextAttach = false + cancelDeveloperToolsRestoreRetry() + } +#if DEBUG + dlog( + "browser.devtools transition.queue panel=\(id.uuidString.prefix(5)) " + + "source=\(source) target=\(targetVisible ? 1 : 0) \(debugDeveloperToolsStateSummary())" + ) +#endif + return true + } + + return performDeveloperToolsVisibilityTransition(to: targetVisible, source: source) + } + + @discardableResult + private func performDeveloperToolsVisibilityTransition( + to targetVisible: Bool, + source: String + ) -> Bool { guard let inspector = webView.cmuxInspectorObject() else { return false } + let isVisibleSelector = NSSelectorFromString("isVisible") let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false - let targetVisible = !visible - let selector = NSSelectorFromString(targetVisible ? "show" : "close") - guard inspector.responds(to: selector) else { return false } - inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = targetVisible + developerToolsTransitionTargetVisible = targetVisible + if targetVisible { - let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false - if visibleAfterToggle { + if !visible { + _ = revealDeveloperTools(inspector) + } else { + developerToolsDetachedOpenGraceDeadline = nil + } + } else { + if visible { + syncDeveloperToolsPresentationPreferenceFromUI() + guard concealDeveloperTools(inspector) else { + developerToolsTransitionTargetVisible = nil + return false + } + } + developerToolsDetachedOpenGraceDeadline = nil + } + + if targetVisible { + let visibleAfterTransition = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false + if visibleAfterTransition { + syncDeveloperToolsPresentationPreferenceFromUI() cancelDeveloperToolsRestoreRetry() + scheduleDetachedDeveloperToolsWindowDismissal() } else { developerToolsRestoreRetryAttempt = 0 scheduleDeveloperToolsRestoreRetry() @@ -1930,6 +3340,26 @@ extension BrowserPanel { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false } + + if visible != targetVisible { + scheduleDeveloperToolsTransitionSettle(source: source) + } else { + developerToolsTransitionTargetVisible = nil + } + + return true + } + + @discardableResult + func toggleDeveloperTools() -> Bool { +#if DEBUG + dlog( + "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) +#endif + let targetVisible = !effectiveDeveloperToolsVisibilityIntent() + let handled = enqueueDeveloperToolsVisibilityTransition(to: targetVisible, source: "toggle") #if DEBUG dlog( "browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " + @@ -1943,30 +3373,18 @@ extension BrowserPanel { ) } #endif - return true + return handled } @discardableResult func showDeveloperTools() -> Bool { - guard let inspector = webView.cmuxInspectorObject() else { return false } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if !visible { - let showSelector = NSSelectorFromString("show") - guard inspector.responds(to: showSelector) else { return false } - inspector.cmuxCallVoid(selector: showSelector) - } - preferredDeveloperToolsVisible = true - if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) { - cancelDeveloperToolsRestoreRetry() - } else { - scheduleDeveloperToolsRestoreRetry() - } - return true + return enqueueDeveloperToolsVisibilityTransition(to: true, source: "show") } @discardableResult func showDeveloperToolsConsole() -> Bool { guard showDeveloperTools() else { return false } + guard !isDeveloperToolsTransitionInFlight else { return true } guard let inspector = webView.cmuxInspectorObject() else { return true } // WebKit private inspector API differs by OS; try known console selectors. let consoleSelectors = [ @@ -1988,7 +3406,23 @@ extension BrowserPanel { func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) { guard let inspector = webView.cmuxInspectorObject() else { return } guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return } + if isDeveloperToolsTransitionInFlight { + let targetVisible = pendingDeveloperToolsTransitionTargetVisible ?? developerToolsTransitionTargetVisible ?? visible + preferredDeveloperToolsVisible = targetVisible + if targetVisible, visible { + developerToolsDetachedOpenGraceDeadline = nil + syncDeveloperToolsPresentationPreferenceFromUI() + cancelDeveloperToolsRestoreRetry() + } else if !targetVisible { + developerToolsDetachedOpenGraceDeadline = nil + forceDeveloperToolsRefreshOnNextAttach = false + cancelDeveloperToolsRestoreRetry() + } + return + } if visible { + developerToolsDetachedOpenGraceDeadline = nil + syncDeveloperToolsPresentationPreferenceFromUI() preferredDeveloperToolsVisible = true cancelDeveloperToolsRestoreRetry() return @@ -2007,6 +3441,7 @@ extension BrowserPanel { forceDeveloperToolsRefreshOnNextAttach = false return } + guard !isDeveloperToolsTransitionInFlight else { return } guard let inspector = webView.cmuxInspectorObject() else { scheduleDeveloperToolsRestoreRetry() return @@ -2017,6 +3452,8 @@ extension BrowserPanel { let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visible { + developerToolsDetachedOpenGraceDeadline = nil + syncDeveloperToolsPresentationPreferenceFromUI() #if DEBUG if shouldForceRefresh { dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") @@ -2026,21 +3463,37 @@ extension BrowserPanel { return } - let selector = NSSelectorFromString("show") - guard inspector.responds(to: selector) else { + let detachedOpenStillSettling = developerToolsDetachedOpenGraceDeadline.map { $0 > Date() } ?? false + if preferredDeveloperToolsPresentation == .detached && !detachedOpenStillSettling { + preferredDeveloperToolsVisible = false + developerToolsDetachedOpenGraceDeadline = nil cancelDeveloperToolsRestoreRetry() +#if DEBUG + dlog( + "browser.devtools detachedClose.consume panel=\(id.uuidString.prefix(5)) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) +#endif return } + #if DEBUG if shouldForceRefresh { dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif - inspector.cmuxCallVoid(selector: selector) + // WebKit inspector show can trigger transient first-responder churn while + // panel attachment is still stabilizing. Keep this auto-restore path from + // mutating first responder so AppKit doesn't walk tearing-down responder chains. + cmuxWithWindowFirstResponderBypass { + _ = revealDeveloperTools(inspector) + } preferredDeveloperToolsVisible = true let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visibleAfterShow { + syncDeveloperToolsPresentationPreferenceFromUI() cancelDeveloperToolsRestoreRetry() + scheduleDetachedDeveloperToolsWindowDismissal() } else { scheduleDeveloperToolsRestoreRetry() } @@ -2054,24 +3507,14 @@ extension BrowserPanel { @discardableResult func hideDeveloperTools() -> Bool { - guard let inspector = webView.cmuxInspectorObject() else { return false } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if visible { - let selector = NSSelectorFromString("close") - guard inspector.responds(to: selector) else { return false } - inspector.cmuxCallVoid(selector: selector) - } - preferredDeveloperToolsVisible = false - forceDeveloperToolsRefreshOnNextAttach = false - cancelDeveloperToolsRestoreRetry() - return true + return enqueueDeveloperToolsVisibilityTransition(to: false, source: "hide") } /// During split/layout transitions SwiftUI can briefly mark the browser surface hidden /// while its container is off-window. Avoid detaching in that transient phase if /// DevTools is intended to remain open, because detach/reattach can blank inspector content. func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool { - preferredDeveloperToolsVisible + preferredDeveloperToolsVisible && !hasSideDockedDeveloperToolsLayout() } func requestDeveloperToolsRefreshAfterNextAttach(reason: String) { @@ -2086,6 +3529,38 @@ extension BrowserPanel { forceDeveloperToolsRefreshOnNextAttach } + func shouldPreserveDeveloperToolsIntentWhileDetached() -> Bool { + preferredDeveloperToolsVisible && + ( + forceDeveloperToolsRefreshOnNextAttach || + developerToolsRestoreRetryWorkItem != nil || + webView.superview == nil || + webView.window == nil + ) + } + + func shouldUseLocalInlineDeveloperToolsHosting() -> Bool { + guard preferredDeveloperToolsVisible || isDeveloperToolsVisible() else { return false } + if preferredDeveloperToolsPresentation == .detached { + return false + } + return detachedDeveloperToolsWindows().isEmpty + } + + func recordPreferredAttachedDeveloperToolsWidth(_ width: CGFloat, containerBounds: NSRect) { + let normalizedWidth = max(0, width) + preferredAttachedDeveloperToolsWidth = normalizedWidth + guard containerBounds.width > 0 else { + preferredAttachedDeveloperToolsWidthFraction = nil + return + } + preferredAttachedDeveloperToolsWidthFraction = normalizedWidth / containerBounds.width + } + + func preferredAttachedDeveloperToolsWidthState() -> (width: CGFloat?, widthFraction: CGFloat?) { + (preferredAttachedDeveloperToolsWidth, preferredAttachedDeveloperToolsWidthFraction) + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) @@ -2119,26 +3594,169 @@ extension BrowserPanel { try await webView.evaluateJavaScript(script) } - func setForcedDarkMode(enabled: Bool, opacity: Double) { - forcedDarkModeEnabled = enabled - forcedDarkModeOpacity = BrowserForcedDarkModeSettings.normalizedOpacity(opacity) - applyForcedDarkModeIfNeeded() + // MARK: - Find in Page + + func startFind() { + preferredFocusIntent = .findField + let created = searchState == nil + if created { + searchState = BrowserSearchState() + } + let generation = beginSearchFocusRequest(reason: "startFind") +#if DEBUG + let window = webView.window + dlog( + "browser.find.start panel=\(id.uuidString.prefix(5)) " + + "created=\(created ? 1 : 0) render=\(shouldRenderWebView ? 1 : 0) " + + "generation=\(generation) " + + "window=\(window?.windowNumber ?? -1) key=\(NSApp.keyWindow === window ? 1 : 0) " + + "firstResponder=\(String(describing: window?.firstResponder))" + ) +#endif + postBrowserSearchFocusNotification(reason: "immediate", generation: generation) + // Focus notification can race with portal overlay mount. Re-post on the + // next runloop and shortly after so the find field can claim first responder. + DispatchQueue.main.async { [weak self] in + self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation) + } + } + + private func postBrowserSearchFocusNotification(reason: String, generation: UInt64) { + guard canApplySearchFocusRequest(generation) else { +#if DEBUG + dlog( + "browser.find.focusNotification.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) generation=\(generation)" + ) +#endif + return + } +#if DEBUG + let window = webView.window + dlog( + "browser.find.focusNotification panel=\(id.uuidString.prefix(5)) " + + "generation=\(generation) " + + "reason=\(reason) window=\(window?.windowNumber ?? -1) " + + "firstResponder=\(String(describing: window?.firstResponder))" + ) +#endif + NotificationCenter.default.post(name: .browserSearchFocus, object: id) + } + + func findNext() { + Task { @MainActor [weak self] in + guard let self else { return } + let result = try? await self.webView.evaluateJavaScript(BrowserFindJavaScript.nextScript()) + self.parseFindResult(result) + } + } + + func findPrevious() { + Task { @MainActor [weak self] in + guard let self else { return } + let result = try? await self.webView.evaluateJavaScript(BrowserFindJavaScript.previousScript()) + self.parseFindResult(result) + } + } + + func hideFind() { + invalidateSearchFocusRequests(reason: "hideFind") + searchState = nil + } + + private func restoreFindStateAfterNavigation(replaySearch: Bool) { + guard let state = searchState else { return } + state.total = nil + state.selected = nil + if replaySearch, !state.needle.isEmpty { + executeFindSearch(state.needle) + } + postBrowserSearchFocusNotification( + reason: "restoreAfterNavigation", + generation: searchFocusRequestGeneration + ) + } + + private func executeFindSearch(_ needle: String) { + guard !needle.isEmpty else { + executeFindClear() + searchState?.selected = nil + searchState?.total = nil + return + } + Task { @MainActor [weak self] in + guard let self else { return } + let js = BrowserFindJavaScript.searchScript(query: needle) + do { + let result = try await self.webView.evaluateJavaScript(js) + self.parseFindResult(result) + } catch { + NSLog("Find: browser JS search error: %@", error.localizedDescription) + } + } + } + + private func executeFindClear() { + Task { @MainActor [weak self] in + guard let self else { return } + do { + _ = try await self.webView.evaluateJavaScript(BrowserFindJavaScript.clearScript()) + } catch { + NSLog("Find: browser JS clear error: %@", error.localizedDescription) + } + } + } + + private func parseFindResult(_ result: Any?) { + guard let jsonString = result as? String, + let data = jsonString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let total = json["total"] as? Int, + let current = json["current"] as? Int, + total >= 0, current >= 0 else { + return + } + searchState?.total = UInt(total) + searchState?.selected = total > 0 ? UInt(current) : nil + } + + func setBrowserThemeMode(_ mode: BrowserThemeMode) { + browserThemeMode = mode + applyBrowserThemeModeIfNeeded() } func refreshAppearanceDrivenColors() { - webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor() + webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor() } func suppressOmnibarAutofocus(for seconds: TimeInterval) { suppressOmnibarAutofocusUntil = Date().addingTimeInterval(seconds) +#if DEBUG + dlog( + "browser.focus.omnibarAutofocus.suppress panel=\(id.uuidString.prefix(5)) " + + "seconds=\(String(format: "%.2f", seconds))" + ) +#endif } func suppressWebViewFocus(for seconds: TimeInterval) { suppressWebViewFocusUntil = Date().addingTimeInterval(seconds) +#if DEBUG + dlog( + "browser.focus.webView.suppress panel=\(id.uuidString.prefix(5)) " + + "seconds=\(String(format: "%.2f", seconds))" + ) +#endif } func clearWebViewFocusSuppression() { suppressWebViewFocusUntil = nil +#if DEBUG + dlog("browser.focus.webView.suppress.clear panel=\(id.uuidString.prefix(5))") +#endif } func shouldSuppressOmnibarAutofocus() -> Bool { @@ -2152,6 +3770,9 @@ extension BrowserPanel { if suppressWebViewFocusForAddressBar { return true } + if searchState != nil { + return true + } if let until = suppressWebViewFocusUntil { return Date() < until } @@ -2159,27 +3780,370 @@ extension BrowserPanel { } func beginSuppressWebViewFocusForAddressBar() { + let enteringAddressBar = !suppressWebViewFocusForAddressBar + if enteringAddressBar { +#if DEBUG + dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") +#endif + invalidateAddressBarPageFocusRestoreAttempts() + } suppressWebViewFocusForAddressBar = true + if enteringAddressBar { + captureAddressBarPageFocusIfNeeded() + } } func endSuppressWebViewFocusForAddressBar() { + if suppressWebViewFocusForAddressBar { +#if DEBUG + dlog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))") +#endif + } suppressWebViewFocusForAddressBar = false } @discardableResult func requestAddressBarFocus() -> UUID { + preferredFocusIntent = .addressBar + invalidateSearchFocusRequests(reason: "requestAddressBarFocus") beginSuppressWebViewFocusForAddressBar() if let pendingAddressBarFocusRequestId { +#if DEBUG + dlog( + "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingAddressBarFocusRequestId.uuidString.prefix(8)) result=reuse_pending" + ) +#endif return pendingAddressBarFocusRequestId } let requestId = UUID() pendingAddressBarFocusRequestId = requestId +#if DEBUG + dlog( + "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=new" + ) +#endif return requestId } + func noteWebViewFocused() { + guard searchState == nil else { return } + guard preferredFocusIntent != .webView else { return } + preferredFocusIntent = .webView + invalidateSearchFocusRequests(reason: "webViewFocused") + } + + func noteAddressBarFocused() { + guard preferredFocusIntent != .addressBar else { return } + preferredFocusIntent = .addressBar + invalidateSearchFocusRequests(reason: "addressBarFocused") + } + + func noteFindFieldFocused() { + guard preferredFocusIntent != .findField else { return } + preferredFocusIntent = .findField + } + + func canApplySearchFocusRequest(_ generation: UInt64) -> Bool { + generation != 0 && + generation == searchFocusRequestGeneration && + searchState != nil && + preferredFocusIntent == .findField + } + + func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { + if pendingAddressBarFocusRequestId != nil || AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id { + return .browser(.addressBar) + } + + if searchState != nil && preferredFocusIntent == .findField { + return .browser(.findField) + } + + if let window, + Self.responderChainContains(window.firstResponder, target: webView) { + return .browser(.webView) + } + + return .browser(preferredFocusIntent) + } + + func preferredFocusIntentForActivation() -> PanelFocusIntent { + if pendingAddressBarFocusRequestId != nil { + return .browser(.addressBar) + } + if searchState != nil && preferredFocusIntent == .findField { + return .browser(.findField) + } + return .browser(preferredFocusIntent) + } + + func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { + guard case .browser(let target) = intent else { return } + + switch target { + case .webView: + preferredFocusIntent = .webView + invalidateSearchFocusRequests(reason: "prepareWebView") + endSuppressWebViewFocusForAddressBar() + case .addressBar: + preferredFocusIntent = .addressBar + invalidateSearchFocusRequests(reason: "prepareAddressBar") + beginSuppressWebViewFocusForAddressBar() + case .findField: + preferredFocusIntent = .findField + } +#if DEBUG + dlog( + "browser.focus.prepare panel=\(id.uuidString.prefix(5)) " + + "target=\(String(describing: target)) suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0)" + ) +#endif + } + + @discardableResult + func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool { + guard case .browser(let target) = intent else { return false } + + switch target { + case .webView: + noteWebViewFocused() + focus() + return true + case .addressBar: + let requestId = requestAddressBarFocus() + NotificationCenter.default.post(name: .browserFocusAddressBar, object: id) +#if DEBUG + dlog( + "browser.focus.restore panel=\(id.uuidString.prefix(5)) " + + "target=addressBar request=\(requestId.uuidString.prefix(8))" + ) +#endif + return true + case .findField: + startFind() + return true + } + } + + func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { + if AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id { + return .browser(.addressBar) + } + + if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) == id { + return .browser(.findField) + } + + if Self.responderChainContains(responder, target: webView) { + return .browser(.webView) + } + + return nil + } + + @discardableResult + func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { + guard case .browser(let target) = intent else { return false } + + switch target { + case .findField: + invalidateSearchFocusRequests(reason: "yieldFindField") + let yielded = BrowserWindowPortalRegistry.yieldSearchOverlayFocusIfOwned(by: id, in: window) +#if DEBUG + if yielded { + dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind") + } +#endif + return yielded + case .addressBar: + guard AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id else { return false } + let yielded = window.makeFirstResponder(nil) +#if DEBUG + if yielded { + dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=addressBar") + } +#endif + return yielded + case .webView: + guard Self.responderChainContains(window.firstResponder, target: webView) else { return false } + return window.makeFirstResponder(nil) + } + } + + @discardableResult + private func beginSearchFocusRequest(reason: String) -> UInt64 { + searchFocusRequestGeneration &+= 1 +#if DEBUG + dlog( + "browser.find.focusLease.begin panel=\(id.uuidString.prefix(5)) " + + "generation=\(searchFocusRequestGeneration) reason=\(reason)" + ) +#endif + return searchFocusRequestGeneration + } + + private func invalidateSearchFocusRequests(reason: String) { + searchFocusRequestGeneration &+= 1 +#if DEBUG + dlog( + "browser.find.focusLease.invalidate panel=\(id.uuidString.prefix(5)) " + + "generation=\(searchFocusRequestGeneration) reason=\(reason)" + ) +#endif + } + func acknowledgeAddressBarFocusRequest(_ requestId: UUID) { - guard pendingAddressBarFocusRequestId == requestId else { return } + guard pendingAddressBarFocusRequestId == requestId else { +#if DEBUG + dlog( + "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=ignored " + + "pending=\(pendingAddressBarFocusRequestId?.uuidString.prefix(8) ?? "nil")" + ) +#endif + return + } pendingAddressBarFocusRequestId = nil +#if DEBUG + dlog( + "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=cleared" + ) +#endif + } + + private func captureAddressBarPageFocusIfNeeded() { + webView.evaluateJavaScript(Self.addressBarFocusCaptureScript) { [weak self] result, error in +#if DEBUG + guard let self else { return } + if let error { + dlog( + "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + + "result=error message=\(error.localizedDescription)" + ) + return + } + let resultValue = (result as? String) ?? "unknown" + dlog( + "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + + "result=\(resultValue)" + ) +#else + _ = self + _ = result + _ = error +#endif + } + } + + private enum AddressBarPageFocusRestoreStatus: String { + case restored + case noState = "no_state" + case missingTarget = "missing_target" + case notFocused = "not_focused" + case error + } + + private static func addressBarPageFocusRestoreStatus( + from result: Any?, + error: Error? + ) -> AddressBarPageFocusRestoreStatus { + if error != nil { return .error } + guard let raw = result as? String else { return .error } + return AddressBarPageFocusRestoreStatus(rawValue: raw) ?? .error + } + + func invalidateAddressBarPageFocusRestoreAttempts() { + addressBarFocusRestoreGeneration &+= 1 +#if DEBUG + dlog( + "browser.focus.addressBar.restore.invalidate panel=\(id.uuidString.prefix(5)) " + + "generation=\(addressBarFocusRestoreGeneration)" + ) +#endif + } + + func restoreAddressBarPageFocusIfNeeded(completion: @escaping (Bool) -> Void) { + addressBarFocusRestoreGeneration &+= 1 + let generation = addressBarFocusRestoreGeneration + let delays: [TimeInterval] = [0.0, 0.03, 0.09, 0.2] + restoreAddressBarPageFocusAttemptIfNeeded( + attempt: 0, + delays: delays, + generation: generation, + completion: completion + ) + } + + private func restoreAddressBarPageFocusAttemptIfNeeded( + attempt: Int, + delays: [TimeInterval], + generation: UInt64, + completion: @escaping (Bool) -> Void + ) { + guard generation == addressBarFocusRestoreGeneration else { + completion(false) + return + } + webView.evaluateJavaScript(Self.addressBarFocusRestoreScript) { [weak self] result, error in + guard let self else { + completion(false) + return + } + guard generation == self.addressBarFocusRestoreGeneration else { + completion(false) + return + } + + let status = Self.addressBarPageFocusRestoreStatus(from: result, error: error) + let canRetry = (status == .notFocused || status == .error) + let hasNextAttempt = attempt + 1 < delays.count + +#if DEBUG + if let error { + dlog( + "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + + "attempt=\(attempt) status=\(status.rawValue) " + + "message=\(error.localizedDescription)" + ) + } else { + dlog( + "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + + "attempt=\(attempt) status=\(status.rawValue)" + ) + } +#endif + + if status == .restored { + completion(true) + return + } + + if canRetry && hasNextAttempt { + let delay = delays[attempt + 1] + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { + completion(false) + return + } + guard generation == self.addressBarFocusRestoreGeneration else { + completion(false) + return + } + self.restoreAddressBarPageFocusAttemptIfNeeded( + attempt: attempt + 1, + delays: delays, + generation: generation, + completion: completion + ) + } + return + } + + completion(false) + } } /// Returns the most reliable URL string for omnibar-related matching and UI decisions. @@ -2202,54 +4166,123 @@ extension BrowserPanel { return nil } + private func resolvedCurrentSessionHistoryURL() -> URL? { + if let webViewURL = webView.url, + Self.serializableSessionHistoryURLString(webViewURL) != nil { + return webViewURL + } + if let currentURL, + Self.serializableSessionHistoryURLString(currentURL) != nil { + return currentURL + } + return restoredHistoryCurrentURL + } + + private func refreshNavigationAvailability() { + let resolvedCanGoBack: Bool + let resolvedCanGoForward: Bool + if usesRestoredSessionHistory { + resolvedCanGoBack = !restoredBackHistoryStack.isEmpty + resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty + } else { + resolvedCanGoBack = nativeCanGoBack + resolvedCanGoForward = nativeCanGoForward + } + + if canGoBack != resolvedCanGoBack { + canGoBack = resolvedCanGoBack + } + if canGoForward != resolvedCanGoForward { + canGoForward = resolvedCanGoForward + } + } + + private func abandonRestoredSessionHistoryIfNeeded() { + guard usesRestoredSessionHistory else { return } + usesRestoredSessionHistory = false + restoredBackHistoryStack.removeAll(keepingCapacity: false) + restoredForwardHistoryStack.removeAll(keepingCapacity: false) + restoredHistoryCurrentURL = nil + refreshNavigationAvailability() + } + + private static func serializableSessionHistoryURLString(_ url: URL?) -> String? { + guard let url else { return nil } + let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value != "about:blank" else { return nil } + return value + } + + private static func sanitizedSessionHistoryURL(_ raw: String?) -> URL? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != "about:blank" else { return nil } + return URL(string: trimmed) + } + + private static func sanitizedSessionHistoryURLs(_ values: [String]) -> [URL] { + values.compactMap { sanitizedSessionHistoryURL($0) } + } + } private extension BrowserPanel { - func applyForcedDarkModeIfNeeded() { - let script = makeForcedDarkModeScript( - enabled: forcedDarkModeEnabled, - opacityPercent: forcedDarkModeOpacity - ) + func applyBrowserThemeModeIfNeeded() { + switch browserThemeMode { + case .system: + webView.appearance = nil + case .light: + webView.appearance = NSAppearance(named: .aqua) + case .dark: + webView.appearance = NSAppearance(named: .darkAqua) + } + + let script = makeBrowserThemeModeScript(mode: browserThemeMode) webView.evaluateJavaScript(script) { _, error in #if DEBUG if let error { - dlog("browser.forcedDarkMode error=\(error.localizedDescription)") + dlog("browser.themeMode error=\(error.localizedDescription)") } #endif } } - func makeForcedDarkModeScript(enabled: Bool, opacityPercent: Double) -> String { - let clampedOpacity = BrowserForcedDarkModeSettings.normalizedOpacity(opacityPercent) / 100.0 - let opacityLiteral = String(format: "%.4f", clampedOpacity) - let enabledLiteral = enabled ? "true" : "false" + func makeBrowserThemeModeScript(mode: BrowserThemeMode) -> String { + let colorSchemeLiteral: String + switch mode { + case .system: + colorSchemeLiteral = "null" + case .light: + colorSchemeLiteral = "'light'" + case .dark: + colorSchemeLiteral = "'dark'" + } + return """ (() => { - const overlayId = 'cmux-forced-dark-mode-overlay'; - const shouldEnable = \(enabledLiteral); - const overlayOpacity = \(opacityLiteral); + const metaId = 'cmux-browser-theme-mode-meta'; + const colorScheme = \(colorSchemeLiteral); const root = document.documentElement || document.body; if (!root) return; - let overlay = document.getElementById(overlayId); - if (!overlay) { - overlay = document.createElement('div'); - overlay.id = overlayId; - overlay.style.position = 'fixed'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.right = '0'; - overlay.style.bottom = '0'; - overlay.style.backgroundColor = 'black'; - overlay.style.pointerEvents = 'none'; - overlay.style.zIndex = '2147483647'; - overlay.style.transition = 'opacity 120ms ease'; - overlay.style.opacity = '0'; - root.appendChild(overlay); + let meta = document.getElementById(metaId); + if (colorScheme) { + root.style.setProperty('color-scheme', colorScheme, 'important'); + root.setAttribute('data-cmux-browser-theme', colorScheme); + if (!meta) { + meta = document.createElement('meta'); + meta.id = metaId; + meta.name = 'color-scheme'; + (document.head || root).appendChild(meta); + } + meta.setAttribute('content', colorScheme); + } else { + root.style.removeProperty('color-scheme'); + root.removeAttribute('data-cmux-browser-theme'); + if (meta) { + meta.remove(); + } } - - overlay.style.display = shouldEnable ? 'block' : 'none'; - overlay.style.opacity = shouldEnable ? String(overlayOpacity) : '0'; })(); """ } @@ -2278,6 +4311,32 @@ private extension BrowserPanel { #if DEBUG extension BrowserPanel { + func configureInsecureHTTPAlertHooksForTesting( + alertFactory: @escaping () -> NSAlert, + windowProvider: @escaping () -> NSWindow? + ) { + insecureHTTPAlertFactory = alertFactory + insecureHTTPAlertWindowProvider = windowProvider + } + + func resetInsecureHTTPAlertHooksForTesting() { + insecureHTTPAlertFactory = { NSAlert() } + insecureHTTPAlertWindowProvider = { [weak self] in + self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + } + + func presentInsecureHTTPAlertForTesting( + url: URL, + recordTypedNavigation: Bool = false + ) { + presentInsecureHTTPAlert( + for: URLRequest(url: url), + intent: .currentTab, + recordTypedNavigation: recordTypedNavigation + ) + } + private static func debugRectDescription(_ rect: NSRect) -> String { String( format: "%.1f,%.1f %.1fx%.1f", @@ -2314,7 +4373,9 @@ extension BrowserPanel { let attached = webView.superview == nil ? 0 : 1 let inWindow = webView.window == nil ? 0 : 1 let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 - return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" + let transitionTarget = developerToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" + let pendingTarget = pendingDeveloperToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" + return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh) tx=\(transitionTarget) pending=\(pendingTarget)" } func debugDeveloperToolsGeometrySummary() -> String { @@ -2328,6 +4389,7 @@ extension BrowserPanel { let containerType = container.map { String(describing: type(of: $0)) } ?? "nil" return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)" } + } #endif @@ -2352,9 +4414,74 @@ private extension BrowserPanel { } return false } + + func hasSideDockedDeveloperToolsLayout() -> Bool { + guard let container = webView.superview else { return false } + return Self.visibleDescendants(in: container) + .filter { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) } + .contains { inspectorCandidate in + hasSideDockedInspectorSibling(startingAt: inspectorCandidate, root: container) + } + } + + func hasSideDockedInspectorSibling(startingAt inspectorLeaf: NSView, root: NSView) -> Bool { + var current: NSView? = inspectorLeaf + + while let inspectorView = current, inspectorView !== root { + guard let containerView = inspectorView.superview else { break } + let hasSideDockedSibling = containerView.subviews.contains { candidate in + guard Self.isVisibleSideDockSiblingCandidate(candidate) else { return false } + guard candidate !== inspectorView else { return false } + let horizontallyAdjacent = + candidate.frame.maxX <= inspectorView.frame.minX + 1 || + candidate.frame.minX >= inspectorView.frame.maxX - 1 + guard horizontallyAdjacent else { return false } + return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 + } + if hasSideDockedSibling { + return true + } + + current = containerView + } + + return false + } + + static func visibleDescendants(in root: NSView) -> [NSView] { + var descendants: [NSView] = [] + var stack = Array(root.subviews.reversed()) + while let view = stack.popLast() { + descendants.append(view) + stack.append(contentsOf: view.subviews.reversed()) + } + return descendants + } + + static func isInspectorView(_ view: NSView) -> Bool { + String(describing: type(of: view)).contains("WKInspector") + } + + static func isVisibleSideDockInspectorCandidate(_ view: NSView) -> Bool { + !view.isHidden && + view.alphaValue > 0 && + view.frame.width > 1 && + view.frame.height > 1 + } + + static func isVisibleSideDockSiblingCandidate(_ view: NSView) -> Bool { + !view.isHidden && + view.alphaValue > 0 && + view.frame.width > 1 && + view.frame.height > 1 + } + + static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { + max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) + } } -private extension WKWebView { +extension WKWebView { func cmuxInspectorObject() -> NSObject? { let selector = NSSelectorFromString("_inspector") guard responds(to: selector), @@ -2363,6 +4490,16 @@ private extension WKWebView { } return inspector } + + func cmuxInspectorFrontendWebView() -> WKWebView? { + guard let inspector = cmuxInspectorObject() else { return nil } + let selector = NSSelectorFromString("inspectorWebView") + guard inspector.responds(to: selector), + let inspectorWebView = inspector.perform(selector)?.takeUnretainedValue() as? WKWebView else { + return nil + } + return inspectorWebView + } } private extension NSObject { @@ -2510,9 +4647,43 @@ private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { // MARK: - Navigation Delegate +func browserNavigationShouldOpenInNewTab( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + guard navigationType == .linkActivated || navigationType == .other else { + return false + } + + if modifierFlags.contains(.command) { + return true + } + if buttonNumber == 2 { + return true + } + // In some WebKit paths, middle-click arrives as buttonNumber=4. + // Recover intent when we just observed a local middle-click. + if buttonNumber == 4, hasRecentMiddleClickIntent { + return true + } + + // WebKit can omit buttonNumber for middle-click link activations. + if let currentEventType, + (currentEventType == .otherMouseDown || currentEventType == .otherMouseUp), + currentEventButtonNumber == 2 { + return true + } + return false +} + private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? + var didTerminateWebContentProcess: ((WKWebView) -> Void)? var openInNewTab: ((URL) -> Void)? var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? @@ -2532,6 +4703,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { NSLog("BrowserPanel navigation failed: %@", error.localizedDescription) + // Treat committed-navigation failures the same as provisional ones so + // stale favicon/title state from the prior page gets cleared. + let failedURL = webView.url?.absoluteString ?? "" + didFailNavigation?(webView, failedURL) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { @@ -2557,9 +4732,29 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { loadErrorPage(in: webView, failedURL: failedURL, error: nsError) } - func webView(_ webView: WKWebView, webContentProcessDidTerminate: WKWebView) { - NSLog("BrowserPanel web content process terminated, reloading") - webView.reload() + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + // WKWebView rejects all authentication challenges by default when this + // delegate method is not implemented (.rejectProtectionSpace). This + // breaks TLS client-certificate flows such as Microsoft Entra ID + // Conditional Access, which verifies device compliance via a client + // certificate stored in the system keychain by MDM enrollment. + // + // By returning .performDefaultHandling the system's standard URL-loading + // behaviour takes over: the keychain is searched for matching client + // identities, MDM-installed root CAs are trusted, and any configured SSO + // extensions (e.g. Microsoft Enterprise SSO) can intercept the challenge. + completionHandler(.performDefaultHandling, nil) + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { +#if DEBUG + dlog("browser.webcontent.terminated panel=\(String(describing: self))") +#endif + didTerminateWebContentProcess?(webView) } private func loadErrorPage(in webView: WKWebView, failedURL: String, error: NSError) { @@ -2570,29 +4765,40 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { case (NSURLErrorDomain, NSURLErrorCannotConnectToHost), (NSURLErrorDomain, NSURLErrorCannotFindHost), (NSURLErrorDomain, NSURLErrorTimedOut): - title = "Can\u{2019}t reach this page" - message = "\(failedURL.isEmpty ? "The site" : failedURL) refused to connect. Check that a server is running on this address." + title = String(localized: "browser.error.cantReach.title", defaultValue: "Can\u{2019}t reach this page") + if failedURL.isEmpty { + message = String(localized: "browser.error.cantReach.messageSite", defaultValue: "The site refused to connect. Check that a server is running on this address.") + } else { + message = String(localized: "browser.error.cantReach.messageURL", defaultValue: "\(failedURL) refused to connect. Check that a server is running on this address.") + } case (NSURLErrorDomain, NSURLErrorNotConnectedToInternet), (NSURLErrorDomain, NSURLErrorNetworkConnectionLost): - title = "No internet connection" - message = "Check your network connection and try again." + title = String(localized: "browser.error.noInternet", defaultValue: "No internet connection") + message = String(localized: "browser.error.checkNetwork", defaultValue: "Check your network connection and try again.") case (NSURLErrorDomain, NSURLErrorSecureConnectionFailed), (NSURLErrorDomain, NSURLErrorServerCertificateUntrusted), (NSURLErrorDomain, NSURLErrorServerCertificateHasUnknownRoot), (NSURLErrorDomain, NSURLErrorServerCertificateHasBadDate), (NSURLErrorDomain, NSURLErrorServerCertificateNotYetValid): - title = "Connection isn\u{2019}t secure" - message = "The certificate for this site is invalid." + title = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") + message = String(localized: "browser.error.invalidCertificate", defaultValue: "The certificate for this site is invalid.") default: - title = "Can\u{2019}t open this page" + title = String(localized: "browser.error.cantOpen.title", defaultValue: "Can\u{2019}t open this page") message = error.localizedDescription } - let escapedURL = failedURL - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) + let escapeHTML: (String) -> String = { value in + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } + + let escapedTitle = escapeHTML(title) + let escapedMessage = escapeHTML(message) + let escapedURL = escapeHTML(failedURL) + let escapedReloadLabel = escapeHTML(String(localized: "browser.error.reload", defaultValue: "Reload")) let html = """ <!DOCTYPE html> @@ -2628,10 +4834,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { </head> <body> <div class="container"> - <h1>\(title)</h1> - <p>\(message)</p> + <h1>\(escapedTitle)</h1> + <p>\(escapedMessage)</p> <div class="url">\(escapedURL)</div> - <button onclick="location.reload()">Reload</button> + <button onclick="location.reload()">\(escapedReloadLabel)</button> </div> </body> </html> @@ -2644,38 +4850,88 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { + let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView) + let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent + ) +#if DEBUG + let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" + let navType = String(describing: navigationAction.navigationType) + dlog( + "browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " + + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + + "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + + "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" + ) +#endif + if let url = navigationAction.request.url, navigationAction.targetFrame?.isMainFrame != false, shouldBlockInsecureHTTPNavigation?(url) == true { let intent: BrowserInsecureHTTPNavigationIntent - if navigationAction.navigationType == .linkActivated, - navigationAction.modifierFlags.contains(.command) { + if shouldOpenInNewTab || navigationAction.targetFrame == nil { intent = .newTab } else { intent = .currentTab } +#if DEBUG + dlog( + "browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " + + "url=\(url.absoluteString)" + ) +#endif handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent) decisionHandler(.cancel) return } - // target=_blank or window.open() — navigate in the current webview - if navigationAction.targetFrame == nil, - navigationAction.request.url != nil { - webView.load(navigationAction.request) + // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). + // Hand these off to macOS so the owning app can handle them. + if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, + browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif decisionHandler(.cancel) return } - // Cmd+click on a regular link — open in a new tab - if navigationAction.navigationType == .linkActivated, - navigationAction.modifierFlags.contains(.command), + // Cmd+click and middle-click on regular links should always open in a new tab. + if shouldOpenInNewTab, let url = navigationAction.request.url { +#if DEBUG + dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)") +#endif openInNewTab?(url) decisionHandler(.cancel) return } + // target=_blank or window.open() — open in a new tab. + if navigationAction.targetFrame == nil, + let url = navigationAction.request.url { +#if DEBUG + dlog("browser.nav.decidePolicy.action kind=openInNewTabFromNilTarget url=\(url.absoluteString)") +#endif + openInNewTab?(url) + decisionHandler(.cancel) + return + } + +#if DEBUG + let targetURL = navigationAction.request.url?.absoluteString ?? "nil" + dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") +#endif decisionHandler(.allow) } @@ -2756,9 +5012,9 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { private func javaScriptDialogTitle(for webView: WKWebView) -> String { if let absolute = webView.url?.absoluteString, !absolute.isEmpty { - return "The page at \(absolute) says:" + return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:") } - return "This page says:" + return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:") } private func presentDialog( @@ -2774,22 +5030,52 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } /// Returning nil tells WebKit not to open a new window. - /// Cmd+click opens in a new tab; regular target=_blank navigates in-place. + /// createWebViewWith is only called when the page requests a new window + /// (window.open(), target=_blank, etc.). Always open in a new tab. func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { + // createWebViewWith is only called when the page requests a new window, + // so always treat as new-tab intent regardless of modifiers/button. +#if DEBUG + let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" + let navType = String(describing: navigationAction.navigationType) + dlog( + "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + + "openInNewTab=1" + ) +#endif if let url = navigationAction.request.url { + if browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + return nil + } if let requestNavigation { - let intent: BrowserInsecureHTTPNavigationIntent = - navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab + let intent: BrowserInsecureHTTPNavigationIntent = .newTab +#if DEBUG + dlog( + "browser.nav.createWebView.action kind=requestNavigation intent=newTab " + + "url=\(url.absoluteString)" + ) +#endif requestNavigation(navigationAction.request, intent) - } else if navigationAction.modifierFlags.contains(.command) { - openInNewTab?(url) } else { - webView.load(navigationAction.request) +#if DEBUG + dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)") +#endif + openInNewTab?(url) } } return nil @@ -2811,6 +5097,16 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } } + func webView( + _ webView: WKWebView, + requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, + type: WKMediaCaptureType, + decisionHandler: @escaping (WKPermissionDecision) -> Void + ) { + decisionHandler(.prompt) + } + func webView( _ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, @@ -2821,7 +5117,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { alert.alertStyle = .informational alert.messageText = javaScriptDialogTitle(for: webView) alert.informativeText = message - alert.addButton(withTitle: "OK") + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) presentDialog(alert, for: webView) { _ in completionHandler() } } @@ -2835,8 +5131,8 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { alert.alertStyle = .informational alert.messageText = javaScriptDialogTitle(for: webView) alert.informativeText = message - alert.addButton(withTitle: "OK") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) presentDialog(alert, for: webView) { response in completionHandler(response == .alertFirstButtonReturn) } @@ -2853,8 +5149,8 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { alert.alertStyle = .informational alert.messageText = javaScriptDialogTitle(for: webView) alert.informativeText = prompt - alert.addButton(withTitle: "OK") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) field.stringValue = defaultText ?? "" diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index e11097d1..b7fc0488 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -71,7 +71,7 @@ enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { // Matches Bonsplit tab icon tint for active tabs. return Color(nsColor: .labelColor) case .accent: - return .accentColor + return cmuxAccentColor() case .tertiary: return Color(nsColor: .tertiaryLabelColor) } @@ -155,22 +155,71 @@ private struct OmnibarAddressButtonStyleBody: View { } } +private extension View { + func cmuxFlatSymbolColorRendering() -> some View { + // `symbolColorRenderingMode(.flat)` is not available in the current SDK + // used by CI/local builds. Keep this modifier as a compatibility no-op. + self + } +} + +func resolvedBrowserChromeBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> NSColor { + switch colorScheme { + case .dark, .light: + return themeBackgroundColor + @unknown default: + return themeBackgroundColor + } +} + +func resolvedBrowserChromeColorScheme( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> ColorScheme { + let backgroundColor = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackgroundColor + ) + return backgroundColor.isLightColor ? .light : .dark +} + +func resolvedBrowserOmnibarPillBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> NSColor { + let darkenMix: CGFloat + switch colorScheme { + case .light: + darkenMix = 0.04 + case .dark: + darkenMix = 0.05 + @unknown default: + darkenMix = 0.04 + } + + return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel + let paneId: PaneID let isFocused: Bool let isVisibleInUI: Bool let portalPriority: Int let onRequestPanelFocus: () -> Void @Environment(\.colorScheme) private var colorScheme + @Environment(\.paneDropZone) private var paneDropZone @State private var omnibarState = OmnibarState() @State private var addressBarFocused: Bool = false @AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue @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(BrowserForcedDarkModeSettings.enabledKey) private var forcedDarkModeEnabled = BrowserForcedDarkModeSettings.defaultEnabled - @AppStorage(BrowserForcedDarkModeSettings.opacityKey) private var forcedDarkModeOpacity = BrowserForcedDarkModeSettings.defaultOpacity + @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 = "" @@ -181,10 +230,17 @@ 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 addressBarHeight: CGFloat = 0 @State private var lastHandledAddressBarFocusRequestId: UUID? - private let omnibarPillCornerRadius: CGFloat = 12 + @State private var pendingAddressBarFocusRetryRequestId: UUID? + @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 + @State private var isBrowserThemeMenuPresented = false + @State private var ghosttyBackgroundGeneration: Int = 0 + // 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 = 26 private let addressBarVerticalPadding: CGFloat = 4 @@ -222,31 +278,90 @@ struct BrowserPanelView: View { BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor } - private var normalizedForcedDarkModeOpacity: Double { - BrowserForcedDarkModeSettings.normalizedOpacity(forcedDarkModeOpacity) + private var browserThemeMode: BrowserThemeMode { + BrowserThemeSettings.mode(for: browserThemeModeRaw) + } + + private var browserChromeBackground: Color { + _ = ghosttyBackgroundGeneration + return Color(nsColor: GhosttyBackgroundTheme.currentColor()) } private var browserChromeBackgroundColor: NSColor { - switch colorScheme { - case .dark: - return GhosttyApp.shared.defaultBackgroundColor - case .light: - return .windowBackgroundColor - @unknown default: - return .windowBackgroundColor + _ = ghosttyBackgroundGeneration + return resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() + ) + } + + private var browserChromeColorScheme: ColorScheme { + _ = ghosttyBackgroundGeneration + return resolvedBrowserChromeColorScheme( + for: colorScheme, + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() + ) + } + + private var browserContentAccessibilityIdentifier: String { + "BrowserPanelContent.\(panel.id.uuidString)" + } + + private var omnibarPillBackgroundColor: NSColor { + resolvedBrowserOmnibarPillBackgroundColor( + for: browserChromeColorScheme, + themeBackgroundColor: browserChromeBackgroundColor + ) + } + + private var owningWorkspace: Workspace? { + guard let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: panel.workspaceId) else { + return nil } + return manager.tabs.first(where: { $0.id == panel.workspaceId }) + } + + private var isCurrentPaneOwner: Bool { + guard let currentPaneId = owningWorkspace?.paneId(forPanelId: panel.id) else { + return false + } + return currentPaneId.id == paneId.id } var body: some View { + // Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit + // container. Rendering it here can hide it behind the portal-hosted WKWebView. VStack(spacing: 0) { addressBar + .fixedSize(horizontal: false, vertical: true) webView } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3) - .shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10) - .padding(6) + // Keep Cmd+F usable when the browser is still in the empty new-tab + // state (no WKWebView mounted yet). WebView-backed cases are hosted + // in AppKit by WindowBrowserPortal to avoid layering/clipping issues. + if !panel.shouldRenderWebView, let searchState = panel.searchState { + BrowserSearchOverlay( + panelId: panel.id, + searchState: searchState, + focusRequestGeneration: panel.searchFocusRequestGeneration, + canApplyFocusRequest: { generation in + panel.canApplySearchFocusRequest(generation) + }, + onNext: { panel.findNext() }, + onPrevious: { panel.findPrevious() }, + onClose: { panel.hideFind() }, + onFieldDidFocus: { panel.noteFindFieldFocused() } + ) + } + } + .overlay { + RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) + .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) + .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) + .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } .overlay(alignment: .topLeading) { @@ -266,47 +381,62 @@ 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") .onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in omnibarPillFrame = frame } + .onPreferenceChange(BrowserAddressBarHeightPreferenceKey.self) { height in + addressBarHeight = height + } .onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in // Only handle clicks from our own webview. guard let webView = note.object as? CmuxWebView else { return false } return webView === panel?.webView }) { _ in - 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) +#if DEBUG + dlog( + "browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " + + "isFocused=\(isFocused ? 1 : 0) " + + "addressFocused=\(addressBarFocused ? 1 : 0)" + ) +#endif + if addressBarFocused { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.webViewClickBlur") +#endif + setAddressBarFocused(false, reason: "webView.clickIntent") + } + if !isFocused { + onRequestPanelFocus() } } .onAppear { UserDefaults.standard.register(defaults: [ BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled, - BrowserForcedDarkModeSettings.enabledKey: BrowserForcedDarkModeSettings.defaultEnabled, - BrowserForcedDarkModeSettings.opacityKey: BrowserForcedDarkModeSettings.defaultOpacity, + BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, ]) + let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard) + if browserThemeModeRaw != resolvedThemeMode.rawValue { + browserThemeModeRaw = resolvedThemeMode.rawValue + } panel.refreshAppearanceDrivenColors() - panel.setForcedDarkMode( - enabled: forcedDarkModeEnabled, - opacity: normalizedForcedDarkModeOpacity - ) + 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") refreshEmptyStateImportBrowsers() BrowserHistoryStore.shared.loadIfNeeded() +#if DEBUG + logBrowserFocusState(event: "view.onAppear") +#endif } .onChange(of: panel.focusFlashToken) { _ in triggerFocusFlashAnimation() @@ -320,27 +450,18 @@ struct BrowserPanelView: View { !panel.shouldSuppressWebViewFocus(), addressWasEmpty, !isWebViewBlank() { - addressBarFocused = false + setAddressBarFocused(false, reason: "panel.currentURL.loaded") } if isWebViewBlank() { refreshEmptyStateImportBrowsers() } } - .onChange(of: forcedDarkModeEnabled) { _ in - panel.setForcedDarkMode( - enabled: forcedDarkModeEnabled, - opacity: normalizedForcedDarkModeOpacity - ) - } - .onChange(of: forcedDarkModeOpacity) { _ in - let normalized = BrowserForcedDarkModeSettings.normalizedOpacity(forcedDarkModeOpacity) - if abs(normalized - forcedDarkModeOpacity) > 0.0001 { - forcedDarkModeOpacity = normalized + .onChange(of: browserThemeModeRaw) { _ in + let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw) + if browserThemeModeRaw != normalizedMode.rawValue { + browserThemeModeRaw = normalizedMode.rawValue } - panel.setForcedDarkMode( - enabled: forcedDarkModeEnabled, - opacity: normalized - ) + panel.setBrowserThemeMode(normalizedMode) } .onChange(of: colorScheme) { _ in panel.refreshAppearanceDrivenColors() @@ -349,16 +470,33 @@ struct BrowserPanelView: View { applyPendingAddressBarFocusRequestIfNeeded() } .onChange(of: isFocused) { focused in +#if DEBUG + logBrowserFocusState( + event: "panelFocus.onChange", + detail: "next=\(focused ? 1 : 0)" + ) +#endif // Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive). if focused { applyPendingAddressBarFocusRequestIfNeeded() autoFocusOmnibarIfBlank() } else { + panel.invalidateAddressBarPageFocusRestoreAttempts() hideSuggestions() - addressBarFocused = false + setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused") } + syncWebViewResponderPolicyWithViewState( + reason: "panelFocusChanged", + isPanelFocusedOverride: focused + ) } .onChange(of: addressBarFocused) { focused in +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.onChange", + detail: "next=\(focused ? 1 : 0)" + ) +#endif let urlString = panel.preferredURLStringForOmnibar() ?? "" if focused { panel.beginSuppressWebViewFocusForAddressBar() @@ -366,6 +504,9 @@ struct BrowserPanelView: View { // Only request panel focus if this pane isn't currently focused. When already // focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit. if !isFocused { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.requestPanelFocus") +#endif onRequestPanelFocus() } let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) @@ -384,11 +525,18 @@ struct BrowserPanelView: View { } inlineCompletion = nil } + syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged") +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.onChange.applied") +#endif } .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in guard let panelId = notification.object as? UUID, panelId == panel.id else { return } guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return } guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return } +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.moveSelection", detail: "delta=\(delta)") +#endif let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta)) applyOmnibarEffects(effects) refreshInlineCompletion() @@ -402,9 +550,15 @@ struct BrowserPanelView: View { return panelId == panel.id }) { _ in if addressBarFocused { - addressBarFocused = false +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.externalBlur") +#endif + setAddressBarFocused(false, reason: "notification.externalBlur") } } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + ghosttyBackgroundGeneration &+= 1 + } } private var addressBar: some View { @@ -415,14 +569,26 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") - forcedDarkModeButton - developerToolsButton + if !panel.isShowingNewTabPage { + browserThemeModeButton + developerToolsButton + } } .padding(.horizontal, 8) .padding(.vertical, addressBarVerticalPadding) - .background(Color(nsColor: browserChromeBackgroundColor)) + .background(browserChromeBackground) + .background { + GeometryReader { geo in + Color.clear + .preference( + key: BrowserAddressBarHeightPreferenceKey.self, + value: geo.size.height + ) + } + } // Keep the omnibar stack above WKWebView so the suggestions popup is visible. .zIndex(1) + .environment(\.colorScheme, browserChromeColorScheme) } private var addressBarButtonBar: some View { @@ -441,7 +607,7 @@ struct BrowserPanelView: View { .buttonStyle(OmnibarAddressButtonStyle()) .disabled(!panel.canGoBack) .opacity(panel.canGoBack ? 1.0 : 0.4) - .help("Go Back") + .safeHelp(String(localized: "browser.goBack", defaultValue: "Go Back")) Button(action: { #if DEBUG @@ -457,7 +623,7 @@ struct BrowserPanelView: View { .buttonStyle(OmnibarAddressButtonStyle()) .disabled(!panel.canGoForward) .opacity(panel.canGoForward ? 1.0 : 0.4) - .help("Go Forward") + .safeHelp(String(localized: "browser.goForward", defaultValue: "Go Forward")) Button(action: { if panel.isLoading { @@ -478,18 +644,18 @@ struct BrowserPanelView: View { .contentShape(Rectangle()) } .buttonStyle(OmnibarAddressButtonStyle()) - .help(panel.isLoading ? "Stop" : "Reload") + .safeHelp(panel.isLoading ? String(localized: "browser.stop", defaultValue: "Stop") : String(localized: "browser.reload", defaultValue: "Reload")) if panel.isDownloading { HStack(spacing: 4) { ProgressView() .controlSize(.small) - Text("Downloading...") + Text(String(localized: "browser.downloading", defaultValue: "Downloading...")) .font(.system(size: 11)) .foregroundStyle(.secondary) } .padding(.leading, 6) - .help("Download in progress") + .safeHelp(String(localized: "browser.downloadInProgress", defaultValue: "Download in progress")) } } } @@ -499,37 +665,72 @@ 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(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - .help("Toggle Developer Tools") + .safeHelp(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip(String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools"))) .accessibilityIdentifier("BrowserToggleDevToolsButton") } - private var forcedDarkModeButton: some View { + private var browserThemeModeButton: some View { Button(action: { - forcedDarkModeEnabled.toggle() - panel.setForcedDarkMode( - enabled: forcedDarkModeEnabled, - opacity: normalizedForcedDarkModeOpacity - ) + isBrowserThemeMenuPresented.toggle() }) { - Image(systemName: forcedDarkModeEnabled ? "moon.fill" : "moon") + Image(systemName: browserThemeMode.iconName) + .symbolRenderingMode(.monochrome) + .cmuxFlatSymbolColorRendering() .font(.system(size: devToolsButtonIconSize, weight: .medium)) - .foregroundStyle( - forcedDarkModeEnabled - ? Color.orange - : Color(nsColor: .secondaryLabelColor) - ) + .foregroundStyle(browserThemeModeIconColor) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } - .buttonStyle(.plain) + .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - .help(forcedDarkModeEnabled ? "Forced Dark Mode On" : "Forced Dark Mode Off") - .accessibilityIdentifier("BrowserForcedDarkModeButton") + .popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) { + browserThemeModePopover + } + .safeHelp("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 { @@ -553,7 +754,7 @@ struct BrowserPanelView: View { ), isFocused: $addressBarFocused, inlineCompletion: inlineCompletion, - placeholder: "Search or enter URL", + placeholder: String(localized: "browser.addressBar.placeholder", defaultValue: "Search or enter URL"), onTap: { handleOmnibarTap() }, @@ -564,14 +765,14 @@ struct BrowserPanelView: View { panel.navigateSmart(omnibarState.buffer) hideSuggestions() suppressNextFocusLostRevert = true - addressBarFocused = false + setAddressBarFocused(false, reason: "omnibar.submit.navigate") } }, onEscape: { handleOmnibarEscape() }, onFieldLostFocus: { - addressBarFocused = false + setAddressBarFocused(false, reason: "omnibar.fieldLostFocus") }, onMoveSelection: { delta in guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return } @@ -602,11 +803,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 { @@ -621,33 +822,63 @@ struct BrowserPanelView: View { } private var webView: some View { - Group { + let useLocalInlineDeveloperToolsHosting = + panel.shouldUseLocalInlineDeveloperToolsHosting() && + isVisibleInUI && + isCurrentPaneOwner + + return Group { if panel.shouldRenderWebView { WebViewRepresentable( panel: panel, - shouldAttachWebView: isVisibleInUI, + paneId: paneId, + shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting, + useLocalInlineHosting: useLocalInlineDeveloperToolsHosting, shouldFocusWebView: isFocused && !addressBarFocused, isPanelFocused: isFocused, - portalZPriority: portalPriority + portalZPriority: portalPriority, + paneDropZone: paneDropZone, + searchOverlay: panel.searchState.map { searchState in + BrowserPortalSearchOverlayConfiguration( + panelId: panel.id, + searchState: searchState, + focusRequestGeneration: panel.searchFocusRequestGeneration, + canApplyFocusRequest: { generation in + panel.canApplySearchFocusRequest(generation) + }, + onNext: { panel.findNext() }, + onPrevious: { panel.findPrevious() }, + onClose: { panel.hideFind() }, + onFieldDidFocus: { panel.noteFindFieldFocused() } + ) + }, + paneTopChromeHeight: addressBarHeight ) - // Keep the representable identity stable across bonsplit structural updates. - // This reduces WKWebView reparenting churn (and the associated WebKit crashes). - .id(panel.id) + .accessibilityIdentifier("BrowserWebViewSurface") + // Keep the host stable for normal pane churn, but force a remount when + // BrowserPanel replaces its underlying WKWebView after process termination + // or when the browser moves to a different Bonsplit pane host. + .id("\(panel.webViewInstanceID.uuidString)-\(paneId.id.uuidString)") .contentShape(Rectangle()) + .accessibilityIdentifier(browserContentAccessibilityIdentifier) .simultaneousGesture(TapGesture().onEnded { // Chrome-like behavior: clicking web content while editing the // omnibar should commit blur and revert transient edits. if addressBarFocused { - addressBarFocused = false +#if DEBUG + logBrowserFocusState(event: "webContent.tapBlur") +#endif + setAddressBarFocused(false, reason: "webContent.tapBlur") } }) } else { Color(nsColor: browserChromeBackgroundColor) .contentShape(Rectangle()) + .accessibilityIdentifier(browserContentAccessibilityIdentifier) .onTapGesture { onRequestPanelFocus() if addressBarFocused { - addressBarFocused = false + setAddressBarFocused(false, reason: "placeholderContent.tapBlur") } } } @@ -657,37 +888,214 @@ struct BrowserPanelView: View { emptyBrowserStateOverlay } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .layoutPriority(1) .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, + isPanelFocusedOverride: Bool? = nil + ) { + guard let cmuxWebView = panel.webView as? CmuxWebView else { return } + let isPanelFocused = isPanelFocusedOverride ?? isFocused + let next = isPanelFocused && !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) " + + "panelFocusedUsed=\(isPanelFocused ? 1 : 0)" + ) +#endif + } + cmuxWebView.allowsFirstResponderAcquisition = next + } + + private func setAddressBarFocused(_ focused: Bool, reason: String) { +#if DEBUG + if addressBarFocused == focused { + logBrowserFocusState( + event: "addressBarFocus.write.noop", + detail: "reason=\(reason) value=\(focused ? 1 : 0)" + ) + } else { + logBrowserFocusState( + event: "addressBarFocus.write", + detail: "reason=\(reason) old=\(addressBarFocused ? 1 : 0) new=\(focused ? 1 : 0)" + ) + } +#endif + addressBarFocused = focused + if focused { + panel.noteAddressBarFocused() + } + } + + private func browserFocusResponderChainContains( + _ start: NSResponder?, + target: NSResponder + ) -> Bool { + var current = start + var hops = 0 + while let responder = current, hops < 64 { + if responder === target { return true } + current = responder.nextResponder + hops += 1 + } + return false + } + + private func isPanelFocusedInModel() -> Bool { + guard let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: panel.workspaceId), + manager.selectedTabId == panel.workspaceId, + let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }) else { + return false + } + return workspace.focusedPanelId == panel.id + } + + private func shouldApplyAddressBarExitFallback(in window: NSWindow) -> Bool { + panel.webView.window === window && isPanelFocusedInModel() + } + +#if DEBUG + private func browserFocusWindow() -> NSWindow? { + panel.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + + private func browserFocusResponderDescription(_ responder: NSResponder?) -> String { + guard let responder else { return "nil" } + return String(describing: type(of: responder)) + } + + private func logBrowserFocusState(event: String, detail: String = "") { + let window = browserFocusWindow() + let firstResponder = window?.firstResponder + let firstResponderType = browserFocusResponderDescription(firstResponder) + let webResponder = browserFocusResponderChainContains(firstResponder, target: panel.webView) ? 1 : 0 + var line = + "browser.focus.trace event=\(event) panel=\(panel.id.uuidString.prefix(5)) " + + "panelFocused=\(isFocused ? 1 : 0) addrFocused=\(addressBarFocused ? 1 : 0) " + + "suppressWeb=\(panel.shouldSuppressWebViewFocus() ? 1 : 0) " + + "suppressAuto=\(panel.shouldSuppressOmnibarAutofocus() ? 1 : 0) " + + "webResponder=\(webResponder) win=\(window?.windowNumber ?? -1) fr=\(firstResponderType)" + if let pending = panel.pendingAddressBarFocusRequestId { + line += " pending=\(pending.uuidString.prefix(8))" + } + if !detail.isEmpty { + line += " \(detail)" + } + dlog(line) + } +#endif + private func syncURLFromPanel() { let urlString = panel.preferredURLStringForOmnibar() ?? "" let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString)) 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 clearPendingAddressBarFocusRetry() { + pendingAddressBarFocusRetryRequestId = nil + pendingAddressBarFocusRetryGeneration &+= 1 + } + + private func schedulePendingAddressBarFocusRetryIfNeeded(requestId: UUID) { + guard pendingAddressBarFocusRetryRequestId != requestId else { return } + pendingAddressBarFocusRetryRequestId = requestId + pendingAddressBarFocusRetryGeneration &+= 1 + let generation = pendingAddressBarFocusRetryGeneration + DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { + guard pendingAddressBarFocusRetryGeneration == generation else { return } + pendingAddressBarFocusRetryRequestId = nil + guard panel.pendingAddressBarFocusRequestId == requestId else { return } + applyPendingAddressBarFocusRequestIfNeeded() + } + } + private func applyPendingAddressBarFocusRequestIfNeeded() { - guard let requestId = panel.pendingAddressBarFocusRequestId else { return } - guard lastHandledAddressBarFocusRequestId != requestId else { return } + guard let requestId = panel.pendingAddressBarFocusRequestId else { + clearPendingAddressBarFocusRetry() + return + } + guard !isCommandPaletteVisibleForPanelWindow() else { +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply.skip", + detail: "reason=command_palette_visible request=\(requestId.uuidString.prefix(8))" + ) +#endif + schedulePendingAddressBarFocusRetryIfNeeded(requestId: requestId) + return + } + clearPendingAddressBarFocusRetry() + guard lastHandledAddressBarFocusRequestId != requestId else { +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply.skip", + detail: "reason=already_handled request=\(requestId.uuidString.prefix(8))" + ) +#endif + return + } lastHandledAddressBarFocusRequestId = requestId panel.beginSuppressWebViewFocusForAddressBar() +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply", + detail: "request=\(requestId.uuidString.prefix(8))" + ) +#endif if addressBarFocused { // Re-run focus behavior (select-all/refresh suggestions) when focus is @@ -696,11 +1104,29 @@ struct BrowserPanelView: View { let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) applyOmnibarEffects(effects) refreshInlineCompletion() +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply", + detail: "request=\(requestId.uuidString.prefix(8)) mode=refresh" + ) +#endif } else { - addressBarFocused = true + setAddressBarFocused(true, reason: "request.apply") +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply", + detail: "request=\(requestId.uuidString.prefix(8)) mode=set_focused" + ) +#endif } panel.acknowledgeAddressBarFocusRequest(requestId) +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.ack", + detail: "request=\(requestId.uuidString.prefix(8))" + ) +#endif } private var emptyBrowserStateOverlay: some View { @@ -708,7 +1134,7 @@ struct BrowserPanelView: View { Spacer(minLength: 22) VStack(alignment: .leading, spacing: 8) { - Text("Import browser data") + Text(String(localized: "settings.browser.emptyImport.title", defaultValue: "Import browser data")) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) @@ -717,7 +1143,7 @@ struct BrowserPanelView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - Button("Choose What to Import…") { + Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) { refreshEmptyStateImportBrowsers() BrowserDataImportCoordinator.shared.presentImportDialog() } @@ -750,14 +1176,48 @@ struct BrowserPanelView: View { } private func autoFocusOmnibarIfBlank() { - guard isFocused else { return } - guard !addressBarFocused else { return } + guard isFocused else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=panel_not_focused") +#endif + return + } + guard !addressBarFocused else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=already_focused") +#endif + return + } + guard !isCommandPaletteVisibleForPanelWindow() else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=command_palette_visible") +#endif + return + } // If a test/automation explicitly focused WebKit, don't steal focus back. - guard !panel.shouldSuppressOmnibarAutofocus() else { return } + guard !panel.shouldSuppressOmnibarAutofocus() else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=autofocus_suppressed") +#endif + return + } // If a real navigation is underway (e.g. open_browser https://...), don't steal focus. - guard !panel.webView.isLoading else { return } - guard isWebViewBlank() else { return } - addressBarFocused = true + guard !panel.webView.isLoading else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_loading") +#endif + return + } + guard isWebViewBlank() else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_not_blank") +#endif + return + } + setAddressBarFocused(true, reason: "autoFocus.blank") +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.apply") +#endif } private func refreshEmptyStateImportBrowsers() { @@ -773,14 +1233,23 @@ struct BrowserPanelView: View { } } - private func handleOmnibarTap() { - onRequestPanelFocus() - guard !addressBarFocused else { return } - // `focusPane` converges selection and can transiently move first responder to WebKit. - // Reassert omnibar focus on the next runloop for click-to-type behavior. - DispatchQueue.main.async { - addressBarFocused = true + private func applyBrowserThemeModeSelection(_ mode: BrowserThemeMode) { + if browserThemeModeRaw != mode.rawValue { + browserThemeModeRaw = mode.rawValue } + panel.setBrowserThemeMode(mode) + } + + private func handleOmnibarTap() { +#if DEBUG + logBrowserFocusState(event: "addressBar.tap") +#endif + if !addressBarFocused { + // Mark focused before pane selection converges so WebKit focus is not + // briefly re-acquired during `focusPane`. + setAddressBarFocused(true, reason: "omnibar.tap") + } + onRequestPanelFocus() } private func hideSuggestions() { @@ -811,7 +1280,7 @@ struct BrowserPanelView: View { hideSuggestions() inlineCompletion = nil suppressNextFocusLostRevert = true - addressBarFocused = false + setAddressBarFocused(false, reason: "suggestion.commit") } private func handleOmnibarEscape() { @@ -873,6 +1342,17 @@ struct BrowserPanelView: View { } private func refreshSuggestions() { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + let trimmedQuery = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines) + CmuxTypingTiming.logDuration( + path: "browser.omnibar.refreshSuggestions", + startedAt: typingTimingStart, + extra: "focused=\(addressBarFocused ? 1 : 0) queryLen=\(trimmedQuery.utf8.count) suggestionCount=\(omnibarState.suggestions.count)" + ) + } +#endif suggestionTask?.cancel() suggestionTask = nil isLoadingRemoteSuggestions = false @@ -1112,14 +1592,65 @@ struct BrowserPanelView: View { } if effects.shouldBlurToWebView { hideSuggestions() - addressBarFocused = false + // This transition is stateful: drop omnibar focus suppression before + // attempting responder handoff so WKWebView can actually become first responder. + panel.endSuppressWebViewFocusForAddressBar() + syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.preHandoff") + setAddressBarFocused(false, reason: "effects.blurToWebView") DispatchQueue.main.async { - guard isFocused else { return } guard let window = panel.webView.window, !panel.webView.isHiddenOrHasHiddenAncestor else { return } + guard shouldApplyAddressBarExitFallback(in: window) else { +#if DEBUG + dlog( + "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + + "result=skip_not_focused" + ) +#endif + NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) + return + } + syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.handoff") panel.clearWebViewFocusSuppression() - window.makeFirstResponder(panel.webView) - NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) + let focusedWebView = window.makeFirstResponder(panel.webView) + if focusedWebView { + panel.noteWebViewFocused() + } +#if DEBUG + dlog( + "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + + "focusedWebView=\(focusedWebView ? 1 : 0)" + ) +#endif + panel.restoreAddressBarPageFocusIfNeeded { restored in + guard shouldApplyAddressBarExitFallback(in: window) else { +#if DEBUG + dlog( + "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + + "result=skip_stale_restore restored=\(restored ? 1 : 0)" + ) +#endif + NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) + return + } + var hasWebViewResponder = + browserFocusResponderChainContains(window.firstResponder, target: panel.webView) + if !hasWebViewResponder { + let fallbackFocusedWebView = window.makeFirstResponder(panel.webView) + hasWebViewResponder = fallbackFocusedWebView +#if DEBUG + dlog( + "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + + "fallbackFocusedWebView=\(fallbackFocusedWebView ? 1 : 0) " + + "restored=\(restored ? 1 : 0)" + ) +#endif + } + if hasWebViewResponder { + panel.noteWebViewFocused() + } + NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) + } } } } @@ -1819,6 +2350,14 @@ private struct OmnibarPillFramePreferenceKey: PreferenceKey { } } +private struct BrowserAddressBarHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + // MARK: - Omnibar State Machine struct OmnibarState: Equatable { @@ -2043,7 +2582,7 @@ struct OmnibarSuggestion: Identifiable, Hashable { var trailingBadgeText: String? { switch kind { case .switchToTab: - return "Switch to tab" + return String(localized: "browser.switchToTab", defaultValue: "Switch to tab") default: return nil } @@ -2123,9 +2662,18 @@ struct OmnibarSuggestion: Identifiable, Hashable { } } +func browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: Bool, + nextResponderIsOtherTextField: Bool +) -> Bool { + desiredOmnibarFocus && !nextResponderIsOtherTextField +} + private final class OmnibarNativeTextField: NSTextField { var onPointerDown: (() -> Void)? var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? + /// Anchor index for Shift+click selection extension, reset on non-shift clicks. + private var shiftClickAnchor: Int? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -2143,7 +2691,11 @@ private final class OmnibarNativeTextField: NSTextField { override func mouseDown(with event: NSEvent) { #if DEBUG - dlog("browser.omnibarClick") + let frType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.omnibarClick win=\(window?.windowNumber ?? -1) " + + "fr=\(frType) hasEditor=\(currentEditor() == nil ? 0 : 1)" + ) #endif onPointerDown?() @@ -2151,50 +2703,131 @@ private final class OmnibarNativeTextField: NSTextField { // First click — activate editing and select all (standard URL bar behavior). // Avoids NSTextView's tracking loop which can spin forever if text layout // enters an infinite invalidation cycle (e.g. under memory pressure). - window?.makeFirstResponder(self) + let result = window?.makeFirstResponder(self) ?? false +#if DEBUG + let frAfter = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.omnibarClick.makeFirstResponder result=\(result ? 1 : 0) " + + "win=\(window?.windowNumber ?? -1) fr=\(frAfter)" + ) +#endif currentEditor()?.selectAll(nil) + shiftClickAnchor = nil } else { - // Already editing — allow normal click-to-place-cursor and drag-to-select. - // Guard against a stuck tracking loop by posting a synthetic mouseUp after - // a timeout. IMPORTANT: must use a background queue because super.mouseDown - // blocks the main thread in NSTextView's tracking loop, so - // DispatchQueue.main.asyncAfter would never fire. - let cancelled = DispatchWorkItem { /* sentinel */ } - let windowNumber = window?.windowNumber ?? 0 - let location = event.locationInWindow - DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + 3.0) { - guard !cancelled.isCancelled else { return } - if let fakeUp = NSEvent.mouseEvent( - with: .leftMouseUp, - location: location, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 0.0 - ) { - NSApp.postEvent(fakeUp, atStart: true) - } + // Already editing — place the cursor at the click position without calling + // super.mouseDown, which enters NSTextView's mouse-tracking loop. That loop + // can spin forever when NSTextLayoutManager.enumerateTextLayoutFragments hits + // an infinite invalidation cycle (see #917). The previous mitigation posted a + // synthetic mouseUp via NSApp.postEvent after a timeout, but the tracking loop + // does not always dequeue events from the application event queue, so the hang + // persisted. By positioning the cursor ourselves we avoid the tracking loop + // entirely. Drag-to-select is not supported in this path, but for a single-line + // omnibar this is an acceptable trade-off (double-click to select word and + // Shift+click to extend selection still work via the field editor). + guard let editor = currentEditor() as? NSTextView else { + super.mouseDown(with: event) + return + } + + // Double/triple-click: forward directly to the field editor (NSTextView) + // which handles word and line selection internally. This bypasses + // NSTextField's super.mouseDown (and its problematic tracking loop) + // while preserving multi-click semantics. + if event.clickCount > 1 { + editor.mouseDown(with: event) + shiftClickAnchor = nil + return + } + + let localPoint = editor.convert(event.locationInWindow, from: nil) + let index = editor.characterIndexForInsertion(at: localPoint) + let textLength = (editor.string as NSString).length + let safeIndex = min(index, textLength) + + if event.modifierFlags.contains(.shift) { + // Shift+click: extend the existing selection to the clicked position. + // Use stored anchor to handle bidirectional extension correctly; + // NSRange.location is always the lower index so it cannot serve as + // a directional anchor on its own. + let sel = editor.selectedRange() + let anchor = shiftClickAnchor ?? sel.location + shiftClickAnchor = anchor + let newRange: NSRange + if safeIndex >= anchor { + newRange = NSRange(location: anchor, length: safeIndex - anchor) + } else { + newRange = NSRange(location: safeIndex, length: anchor - safeIndex) + } + editor.setSelectedRange(newRange) + } else { + shiftClickAnchor = nil + editor.setSelectedRange(NSRange(location: safeIndex, length: 0)) } - super.mouseDown(with: event) - cancelled.cancel() } } override func keyDown(with event: NSEvent) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var route = "super" + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.keyDown", + startedAt: typingTimingStart, + event: event, + extra: "route=\(route)" + ) + } +#endif + // Reset shift-click anchor on any keyboard input so that a subsequent + // Shift+click uses the post-keyboard selection as its anchor, not a + // stale value from a prior mouse interaction. + shiftClickAnchor = nil + if (currentEditor() as? NSTextView)?.hasMarkedText() == true { + super.keyDown(with: event) + return + } if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true { +#if DEBUG + route = "custom" +#endif return } super.keyDown(with: event) } override func performKeyEquivalent(with event: NSEvent) -> Bool { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var handled = false + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.performKeyEquivalent", + startedAt: typingTimingStart, + event: event, + extra: "handled=\(handled ? 1 : 0)" + ) + } +#endif + shiftClickAnchor = nil + if (currentEditor() as? NSTextView)?.hasMarkedText() == true { + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result + } if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true { +#if DEBUG + handled = true +#endif return true } - return super.performKeyEquivalent(with: event) + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result } } @@ -2229,15 +2862,128 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { self.parent = parent } +#if DEBUG + func logFocusEvent(_ event: String, detail: String = "") { + let window = parentField?.window + let responder = window?.firstResponder + let responderType = responder.map { String(describing: type(of: $0)) } ?? "nil" + let responderIsField: Int = { + guard let field = parentField else { return 0 } + if responder === field { return 1 } + if let editor = responder as? NSTextView, + (editor.delegate as? NSTextField) === field { + return 1 + } + return 0 + }() + let pendingValue: String = { + guard let pendingFocusRequest else { return "nil" } + return pendingFocusRequest ? "focus" : "blur" + }() + var line = + "browser.focus.field event=\(event) focused=\(parent.isFocused ? 1 : 0) " + + "pending=\(pendingValue) suppressWeb=\(parent.shouldSuppressWebViewFocus() ? 1 : 0) " + + "win=\(window?.windowNumber ?? -1) fr=\(responderType) frIsField=\(responderIsField)" + if !detail.isEmpty { + line += " \(detail)" + } + dlog(line) + } +#endif + deinit { if let selectionObserver { NotificationCenter.default.removeObserver(selectionObserver) } } + 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 isPointerDownEvent(_ event: NSEvent) -> Bool { + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private func topHitViewForCurrentPointerEvent(window: NSWindow) -> NSView? { + guard let event = NSApp.currentEvent, isPointerDownEvent(event) else { + return nil + } + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + + if let contentView = window.contentView, + let themeFrame = contentView.superview { + let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) + if let hitInTheme = themeFrame.hitTest(pointInTheme) { + return hitInTheme + } + } + + guard let contentView = window.contentView else { + return nil + } + let pointInContent = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(pointInContent) + } + + private func pointerDownBlurIntent(window: NSWindow?) -> Bool { + guard let window, let field = parentField else { return false } + guard let hitView = topHitViewForCurrentPointerEvent(window: window) else { + return false + } + + if hitView === field || hitView.isDescendant(of: field) { + return false + } + if let textView = hitView as? NSTextView, + let delegateField = textView.delegate as? NSTextField, + delegateField === field { + return false + } + return true + } + + private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool { + if pointerDownBlurIntent(window: window) { + return false + } + return browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: parent.isFocused, + nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window) + ) + } + func controlTextDidBeginEditing(_ obj: Notification) { +#if DEBUG + logFocusEvent("controlTextDidBeginEditing") +#endif if !parent.isFocused { DispatchQueue.main.async { +#if DEBUG + self.logFocusEvent("controlTextDidBeginEditing.asyncSetFocused", detail: "old=0 new=1") +#endif self.parent.isFocused = true } } @@ -2246,16 +2992,36 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } func controlTextDidEndEditing(_ obj: Notification) { +#if DEBUG + let nextOther = nextResponderIsOtherTextField(window: parentField?.window) + let pointerBlur = pointerDownBlurIntent(window: parentField?.window) + logFocusEvent( + "controlTextDidEndEditing", + detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(pointerBlur ? 1 : 0) shouldReacquire=\(shouldReacquireFocusAfterEndEditing(window: parentField?.window) ? 1 : 0)" + ) +#endif if parent.isFocused { - if parent.shouldSuppressWebViewFocus() { + if shouldReacquireFocusAfterEndEditing(window: parentField?.window) { +#if DEBUG + logFocusEvent("controlTextDidEndEditing.reacquire.begin") +#endif guard pendingFocusRequest != true else { return } pendingFocusRequest = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.pendingFocusRequest = nil +#if DEBUG + self.logFocusEvent("controlTextDidEndEditing.reacquire.tick") +#endif 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 { +#if DEBUG + self.logFocusEvent("controlTextDidEndEditing.reacquire.cancel") +#endif + 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 @@ -2263,17 +3029,38 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { field.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === field if !isAlreadyFocused { +#if DEBUG + self.logFocusEvent("controlTextDidEndEditing.reacquire.apply") +#endif window.makeFirstResponder(field) + } else { +#if DEBUG + self.logFocusEvent("controlTextDidEndEditing.reacquire.skip", detail: "reason=already_focused") +#endif } } return } +#if DEBUG + logFocusEvent("controlTextDidEndEditing.blur") +#endif parent.onFieldLostFocus() } detachSelectionObserver() } func controlTextDidChange(_ obj: Notification) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.controlTextDidChange", + startedAt: typingTimingStart, + event: NSApp.currentEvent, + extra: "programmatic=\(isProgrammaticMutation ? 1 : 0)" + ) + } +#endif guard !isProgrammaticMutation else { return } guard let field = obj.object as? NSTextField else { return } let editor = field.currentEditor() as? NSTextView @@ -2287,36 +3074,69 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var handled = false + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.doCommandBy", + startedAt: typingTimingStart, + event: NSApp.currentEvent, + extra: "handled=\(handled ? 1 : 0) selector=\(NSStringFromSelector(commandSelector))" + ) + } +#endif switch commandSelector { case #selector(NSResponder.moveDown(_:)): parent.onMoveSelection(+1) +#if DEBUG + handled = true +#endif return true case #selector(NSResponder.moveUp(_:)): parent.onMoveSelection(-1) +#if DEBUG + handled = true +#endif return true case #selector(NSResponder.insertNewline(_:)): let currentFlags = NSApp.currentEvent?.modifierFlags ?? [] guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false } parent.onSubmit() +#if DEBUG + handled = true +#endif return true case #selector(NSResponder.cancelOperation(_:)): parent.onEscape() +#if DEBUG + handled = true +#endif return true case #selector(NSResponder.moveRight(_:)), #selector(NSResponder.moveToEndOfLine(_:)): if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() +#if DEBUG + handled = true +#endif return true } return false case #selector(NSResponder.insertTab(_:)): if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() +#if DEBUG + handled = true +#endif return true } return false case #selector(NSResponder.deleteBackward(_:)): if suffixSelectionMatchesInline(textView, inline: parent.inlineCompletion) { parent.onDeleteBackwardWithInlineSelection() +#if DEBUG + handled = true +#endif return true } return false @@ -2384,6 +3204,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } func handleKeyEvent(_ event: NSEvent, editor: NSTextView?) -> Bool { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var handled = false + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.handleKeyEvent", + startedAt: typingTimingStart, + event: event, + extra: "handled=\(handled ? 1 : 0)" + ) + } +#endif let keyCode = event.keyCode let modifiers = event.modifierFlags.intersection([.command, .control, .shift, .option, .function]) let lowered = event.charactersIgnoringModifiers?.lowercased() ?? "" @@ -2392,16 +3224,25 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { // Cmd/Ctrl+N and Cmd/Ctrl+P should repeat while held. if hasCommandOrControl, lowered == "n" { parent.onMoveSelection(+1) +#if DEBUG + handled = true +#endif return true } if hasCommandOrControl, lowered == "p" { parent.onMoveSelection(-1) +#if DEBUG + handled = true +#endif return true } // Shift+Delete removes the selected history suggestion when possible. if modifiers.contains(.shift), (keyCode == 51 || keyCode == 117) { parent.onDeleteSelectedSuggestion() +#if DEBUG + handled = true +#endif return true } @@ -2409,30 +3250,51 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { case 36, 76: // Return / keypad Enter guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false } parent.onSubmit() +#if DEBUG + handled = true +#endif return true case 53: // Escape parent.onEscape() +#if DEBUG + handled = true +#endif return true case 125: // Down parent.onMoveSelection(+1) +#if DEBUG + handled = true +#endif return true case 126: // Up parent.onMoveSelection(-1) +#if DEBUG + handled = true +#endif return true case 124, 119: // Right arrow / End if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() +#if DEBUG + handled = true +#endif return true } case 48: // Tab if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() +#if DEBUG + handled = true +#endif return true } case 51: // Backspace if let inline = parent.inlineCompletion, (suffixSelectionMatchesInline(editor, inline: inline) || selectionIsTypedPrefixBoundary(editor, inline: inline)) { parent.onDeleteBackwardWithInlineSelection() +#if DEBUG + handled = true +#endif return true } default: @@ -2479,7 +3341,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { ) let desiredDisplayText = activeInlineCompletion?.displayText ?? text if let editor = nsView.currentEditor() as? NSTextView { - if editor.string != desiredDisplayText { + if !editor.hasMarkedText(), editor.string != desiredDisplayText { context.coordinator.isProgrammaticMutation = true editor.string = desiredDisplayText nsView.stringValue = desiredDisplayText @@ -2496,34 +3358,72 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { nsView.currentEditor() != nil || ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true { +#if DEBUG + context.coordinator.logFocusEvent( + "updateNSView.requestFocus.begin", + detail: "isFocused=1 isFirstResponder=0" + ) +#endif // Defer to avoid triggering input method XPC during layout pass, // which can crash via re-entrant view hierarchy modification. context.coordinator.pendingFocusRequest = true DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in coordinator?.pendingFocusRequest = nil guard let nsView, let window = nsView.window else { return } +#if DEBUG + if coordinator?.parent.isFocused != true { + coordinator?.logFocusEvent("updateNSView.requestFocus.cancel", detail: "reason=stale_state") + return + } +#endif + guard coordinator?.parent.isFocused == true else { return } +#if DEBUG + coordinator?.logFocusEvent("updateNSView.requestFocus.tick") +#endif let fr = window.firstResponder let alreadyFocused = fr === nsView || nsView.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === nsView guard !alreadyFocused else { return } +#if DEBUG + coordinator?.logFocusEvent("updateNSView.requestFocus.apply") +#endif window.makeFirstResponder(nsView) } } else if !isFocused, isFirstResponder, context.coordinator.pendingFocusRequest != false { +#if DEBUG + context.coordinator.logFocusEvent( + "updateNSView.requestBlur.begin", + detail: "isFocused=0 isFirstResponder=1" + ) +#endif context.coordinator.pendingFocusRequest = false DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in coordinator?.pendingFocusRequest = nil guard let nsView, let window = nsView.window else { return } +#if DEBUG + if coordinator?.parent.isFocused == true { + coordinator?.logFocusEvent("updateNSView.requestBlur.cancel", detail: "reason=stale_state") + return + } +#endif + guard coordinator?.parent.isFocused == false else { return } +#if DEBUG + coordinator?.logFocusEvent("updateNSView.requestBlur.tick") +#endif let fr = window.firstResponder let stillFirst = fr === nsView || ((fr as? NSTextView)?.delegate as? NSTextField) === nsView guard stillFirst else { return } +#if DEBUG + coordinator?.logFocusEvent("updateNSView.requestBlur.apply") +#endif window.makeFirstResponder(nil) } } } - if let editor = nsView.currentEditor() as? NSTextView { + if let editor = nsView.currentEditor() as? NSTextView, !editor.hasMarkedText() { if let activeInlineCompletion { let currentSelection = editor.selectedRange() let desiredSelection = omnibarDesiredSelectionRangeForInlineCompletion( @@ -2568,11 +3468,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 @@ -2625,6 +3526,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) { @@ -2638,18 +3634,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) @@ -2665,7 +3661,7 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous) .fill( idx == selectedIndex - ? Color.white.opacity(0.12) + ? rowHighlightColor : Color.clear ) ) @@ -2720,10 +3716,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 ) @@ -2734,83 +3727,1029 @@ 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") - .accessibilityLabel("Address bar suggestions") + .accessibilityLabel(String(localized: "browser.addressBarSuggestions", defaultValue: "Address bar suggestions")) } } /// NSViewRepresentable wrapper for WKWebView struct WebViewRepresentable: NSViewRepresentable { let panel: BrowserPanel + let paneId: PaneID let shouldAttachWebView: Bool + let useLocalInlineHosting: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool let portalZPriority: Int + let paneDropZone: DropZone? + let searchOverlay: BrowserPortalSearchOverlayConfiguration? + let paneTopChromeHeight: CGFloat final class Coordinator { weak var panel: BrowserPanel? weak var webView: WKWebView? - var attachRetryWorkItem: DispatchWorkItem? - var attachRetryCount: Int = 0 var attachGeneration: Int = 0 - var usesWindowPortal: Bool = false var desiredPortalVisibleInUI: Bool = true var desiredPortalZPriority: Int = 0 var lastPortalHostId: ObjectIdentifier? + var lastSynchronizedHostGeometryRevision: UInt64 = 0 } - private final class HostContainerView: NSView { + final class HostContainerView: NSView { + private final class HostedInspectorSideDockContainerView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override var isOpaque: Bool { false } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + // Managed side-docked DevTools use explicit frame updates from the host. + // Letting AppKit autoresize the WK siblings here makes them snap back to + // stale widths while the divider drag or pane resize is in flight. + } + } + var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? + private(set) var geometryRevision: UInt64 = 0 + private var lastReportedGeometryState: GeometryState? + private weak var hostedWebView: WKWebView? + private var hostedWebViewConstraints: [NSLayoutConstraint] = [] + private weak var localInlineSlotView: WindowBrowserSlotView? + private var localInlineSlotConstraints: [NSLayoutConstraint] = [] + private weak var hostedInspectorSideDockContainerView: HostedInspectorSideDockContainerView? + private var hostedInspectorSideDockConstraints: [NSLayoutConstraint] = [] + private weak var hostedInspectorFrontendWebView: WKWebView? + private struct HostedInspectorDividerHit { + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + let dockSide: HostedInspectorDockSide + } + + private struct GeometryState: Equatable { + let frame: CGRect + let bounds: CGRect + let windowNumber: Int? + let superviewID: ObjectIdentifier? + } + + private struct HostedInspectorDividerDragState { + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + let dockSide: HostedInspectorDockSide + let initialWindowX: CGFloat + let initialPageFrame: NSRect + let initialInspectorFrame: NSRect + } + + private enum DividerCursorKind: Equatable { + case vertical + + var cursor: NSCursor { .resizeLeftRight } + } + + private static let hostedInspectorDividerHitExpansion: CGFloat = 10 + private static let minimumHostedInspectorWidth: CGFloat = 120 + private static let minimumHostedInspectorPageWidthForSideDock: CGFloat = 240 + private static let adaptiveBottomDockRequestCooldown: TimeInterval = 0.25 + private var trackingArea: NSTrackingArea? + private var activeDividerCursorKind: DividerCursorKind? + private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? + private var preferredHostedInspectorWidth: CGFloat? + private var preferredHostedInspectorWidthFraction: CGFloat? + var onPreferredHostedInspectorWidthChanged: ((CGFloat, CGFloat?) -> Void)? + private weak var hostedInspectorSideDockPageView: NSView? + private weak var hostedInspectorSideDockInspectorView: NSView? + private var hostedInspectorSideDockDockSide: HostedInspectorDockSide? + private var isHostedInspectorDividerDragActive = false + private var isApplyingHostedInspectorLayout = false + private var hostedInspectorReapplyWorkItem: DispatchWorkItem? + private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem? + private var adaptiveBottomDockRequestCooldownDeadline: Date? + private var recordedHostedInspectorSideDockWidth: CGFloat? + private var lastHostedInspectorManualSideDockAllowed: Bool? + private var lastHostedInspectorLayoutBoundsSize: NSSize? +#if DEBUG + private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)? + private var hasLoggedMissingHostedInspectorCandidate = false +#endif + + deinit { + hostedInspectorReapplyWorkItem?.cancel() + hostedInspectorDockConfigurationSyncWorkItem?.cancel() + if let trackingArea { + removeTrackingArea(trackingArea) + } + clearActiveDividerCursor(restoreArrow: false) + } + + private func recordPreferredHostedInspectorWidth(_ width: CGFloat, containerBounds: NSRect) { + preferredHostedInspectorWidth = width + guard containerBounds.width > 0 else { + preferredHostedInspectorWidthFraction = nil + onPreferredHostedInspectorWidthChanged?(width, nil) + return + } + preferredHostedInspectorWidthFraction = width / containerBounds.width + onPreferredHostedInspectorWidthChanged?(width, preferredHostedInspectorWidthFraction) + } + + private func resolvedPreferredHostedInspectorWidth(in containerBounds: NSRect) -> CGFloat? { + if let preferredHostedInspectorWidthFraction, containerBounds.width > 0 { + return max(0, containerBounds.width * preferredHostedInspectorWidthFraction) + } + return preferredHostedInspectorWidth + } + + func setPreferredHostedInspectorWidth(width: CGFloat?, widthFraction: CGFloat?) { + preferredHostedInspectorWidth = width + preferredHostedInspectorWidthFraction = widthFraction + } + + private func recordHostedInspectorSideDockWidth(_ width: CGFloat) { + guard width > 1 else { return } + recordedHostedInspectorSideDockWidth = max(Self.minimumHostedInspectorWidth, width) + } + + private func shouldAllowHostedInspectorManualSideDock() -> Bool { + let containerWidth = max(0, bounds.width) + guard containerWidth > 1 else { return true } + let baselineWidth = max( + Self.minimumHostedInspectorWidth, + recordedHostedInspectorSideDockWidth ?? Self.minimumHostedInspectorWidth + ) + return containerWidth - baselineWidth >= Self.minimumHostedInspectorPageWidthForSideDock + } + + private func updateHostedInspectorDockControlAvailabilityIfNeeded(reason: String) { + guard let hostedInspectorFrontendWebView else { + lastHostedInspectorManualSideDockAllowed = nil + return + } + + let sideDockAllowed = shouldAllowHostedInspectorManualSideDock() + guard lastHostedInspectorManualSideDockAllowed != sideDockAllowed else { return } + lastHostedInspectorManualSideDockAllowed = sideDockAllowed + + let sideDockAllowedLiteral = sideDockAllowed ? "true" : "false" +#if DEBUG + let recordedWidthDesc = recordedHostedInspectorSideDockWidth.map { + String(format: "%.1f", $0) + } ?? "nil" + dlog( + "browser.panel.hostedInspector stage=\(reason).dockControls " + + "host=\(Self.debugObjectID(self)) allowSideDock=\(sideDockAllowed ? 1 : 0) " + + "recordedWidth=\(recordedWidthDesc) bounds=\(Self.debugRect(bounds))" + ) +#endif + hostedInspectorFrontendWebView.evaluateJavaScript( + """ + (() => { + if (typeof WI === "undefined") + return null; + const allowSideDock = \(sideDockAllowedLiteral); + if (!WI.__cmuxOriginalUpdateDockNavigationItems && typeof WI._updateDockNavigationItems === "function") + WI.__cmuxOriginalUpdateDockNavigationItems = WI._updateDockNavigationItems; + if (!WI.__cmuxOriginalDockLeft && typeof WI._dockLeft === "function") + WI.__cmuxOriginalDockLeft = WI._dockLeft; + if (!WI.__cmuxOriginalDockRight && typeof WI._dockRight === "function") + WI.__cmuxOriginalDockRight = WI._dockRight; + if (!WI.__cmuxOriginalTogglePreviousDockConfiguration && typeof WI._togglePreviousDockConfiguration === "function") + WI.__cmuxOriginalTogglePreviousDockConfiguration = WI._togglePreviousDockConfiguration; + function callOriginal(fn, event) { + return typeof fn === "function" ? fn.call(WI, event) : null; + } + function updateButton(button, hidden) { + if (!button) + return; + button.hidden = hidden; + if (button.element) { + button.element.style.display = hidden ? "none" : ""; + button.element.style.pointerEvents = hidden ? "none" : ""; + } + } + function enforceDockControls() { + const disallowSideDock = !WI.__cmuxAllowSideDock; + updateButton(WI._dockLeftTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Left); + updateButton(WI._dockRightTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Right); + } + WI.__cmuxAllowSideDock = allowSideDock; + WI._dockLeft = function(event) { + if (!WI.__cmuxAllowSideDock) + return callOriginal(WI._dockBottom, event); + return callOriginal(WI.__cmuxOriginalDockLeft, event); + }; + WI._dockRight = function(event) { + if (!WI.__cmuxAllowSideDock) + return callOriginal(WI._dockBottom, event); + return callOriginal(WI.__cmuxOriginalDockRight, event); + }; + WI._togglePreviousDockConfiguration = function(event) { + const previousSideDock = WI._previousDockConfiguration === WI.DockConfiguration.Left || WI._previousDockConfiguration === WI.DockConfiguration.Right; + if (!WI.__cmuxAllowSideDock && previousSideDock) + return callOriginal(WI._dockBottom, event); + return callOriginal(WI.__cmuxOriginalTogglePreviousDockConfiguration, event); + }; + WI._updateDockNavigationItems = function(...args) { + if (typeof WI.__cmuxOriginalUpdateDockNavigationItems === "function") + WI.__cmuxOriginalUpdateDockNavigationItems.apply(WI, args); + enforceDockControls(); + }; + WI._updateDockNavigationItems(); + return WI.__cmuxAllowSideDock; + })(); + """, + completionHandler: nil + ) + } + + func containsManagedLocalInlineContent(_ view: NSView) -> Bool { + if let localInlineSlotView, + view === localInlineSlotView || view.isDescendant(of: localInlineSlotView) { + return true + } + if let hostedInspectorSideDockContainerView, + view === hostedInspectorSideDockContainerView || view.isDescendant(of: hostedInspectorSideDockContainerView) { + return true + } + return false + } + + func currentHostedWebViewContainer(preferredSlotView: WindowBrowserSlotView) -> NSView { + if let hostedInspectorSideDockContainerView, + let hostedInspectorSideDockPageView, + hostedWebView?.isDescendant(of: hostedInspectorSideDockContainerView) == true, + hostedInspectorSideDockPageView.isDescendant(of: hostedInspectorSideDockContainerView) { + return hostedInspectorSideDockContainerView + } + return preferredSlotView + } + + func setHostedInspectorFrontendWebView(_ webView: WKWebView?) { + hostedInspectorFrontendWebView = webView + lastHostedInspectorManualSideDockAllowed = nil + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "setHostedInspectorFrontendWebView") + } + + private var hasStoredHostedInspectorWidthPreference: Bool { + preferredHostedInspectorWidth != nil || preferredHostedInspectorWidthFraction != nil + } + +#if DEBUG + private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool { + switch event?.type { + case .leftMouseDown, .leftMouseDragged, .leftMouseUp: + return true + default: + return false + } + } + + private func debugLogHitTest(stage: String, point: NSPoint, passThrough: Bool, hitView: NSView?) { + let event = NSApp.currentEvent + guard Self.shouldLogPointerEvent(event) else { return } + + let hitDesc: String = { + guard let hitView else { return "nil" } + let token = Unmanaged.passUnretained(hitView).toOpaque() + return "\(type(of: hitView))@\(token)" + }() + let hostRectInContent: NSRect = { + guard let window, let contentView = window.contentView else { return .zero } + return contentView.convert(bounds, from: self) + }() + dlog( + "browser.panel.host stage=\(stage) event=\(String(describing: event?.type)) " + + "point=\(String(format: "%.1f,%.1f", point.x, point.y)) pass=\(passThrough ? 1 : 0) " + + "hostFrameInContent=\(String(format: "%.1f,%.1f %.1fx%.1f", hostRectInContent.origin.x, hostRectInContent.origin.y, hostRectInContent.width, hostRectInContent.height)) " + + "hit=\(hitDesc)" + ) + } + + private static func debugObjectID(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private static func debugRect(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.width, rect.height) + } + + private func debugLogHostedInspectorFrames( + stage: String, + point: NSPoint? = nil, + hit: HostedInspectorDividerHit + ) { + let pointDesc = point.map { String(format: "%.1f,%.1f", $0.x, $0.y) } ?? "nil" + let preferredWidthDesc = preferredHostedInspectorWidth.map { String(format: "%.1f", $0) } ?? "nil" + dlog( + "browser.panel.hostedInspector stage=\(stage) point=\(pointDesc) " + + "host=\(Self.debugObjectID(self)) container=\(Self.debugObjectID(hit.containerView)) " + + "page=\(Self.debugObjectID(hit.pageView)) inspector=\(Self.debugObjectID(hit.inspectorView)) " + + "preferredWidth=\(preferredWidthDesc) " + + "hostFrame=\(Self.debugRect(frame)) hostBounds=\(Self.debugRect(bounds)) " + + "containerBounds=\(Self.debugRect(hit.containerView.bounds)) " + + "pageFrame=\(Self.debugRect(hit.pageView.frame)) " + + "inspectorFrame=\(Self.debugRect(hit.inspectorView.frame))" + ) + } + + private func debugLogHostedInspectorLayoutIfNeeded(reason: String) { + guard let hit = hostedInspectorDividerCandidate() else { + if !hasLoggedMissingHostedInspectorCandidate, + lastLoggedHostedInspectorFrames != nil || preferredHostedInspectorWidth != nil { + let preferredWidthDesc = preferredHostedInspectorWidth.map { + String(format: "%.1f", $0) + } ?? "nil" + lastLoggedHostedInspectorFrames = nil + hasLoggedMissingHostedInspectorCandidate = true + dlog( + "browser.panel.hostedInspector stage=\(reason).candidateMissing " + + "host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)" + ) + } + return + } + hasLoggedMissingHostedInspectorCandidate = false + + let nextFrames = (page: hit.pageView.frame, inspector: hit.inspectorView.frame) + if let lastLoggedHostedInspectorFrames, + Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.page, nextFrames.page), + Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.inspector, nextFrames.inspector) { + return + } + + lastLoggedHostedInspectorFrames = nextFrames + debugLogHostedInspectorFrames(stage: "\(reason).layout", hit: hit) + } +#endif + + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.width - rhs.width) <= epsilon && + abs(lhs.height - rhs.height) <= epsilon + } + + private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.width - rhs.width) <= epsilon && + abs(lhs.height - rhs.height) <= epsilon + } + + private func currentGeometryState() -> GeometryState { + GeometryState( + frame: frame, + bounds: bounds, + windowNumber: window?.windowNumber, + superviewID: superview.map(ObjectIdentifier.init) + ) + } + + private func notifyGeometryChangedIfNeeded() { + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + lastReportedGeometryState = state + geometryRevision &+= 1 + onGeometryChanged?() + } + + func ensureLocalInlineSlotView() -> WindowBrowserSlotView { + if let localInlineSlotView, localInlineSlotView.superview === self { + localInlineSlotView.isHidden = false + return localInlineSlotView + } + + let slotView = WindowBrowserSlotView(frame: bounds) + slotView.translatesAutoresizingMaskIntoConstraints = false + addSubview(slotView, positioned: .above, relativeTo: nil) + localInlineSlotConstraints = [ + slotView.topAnchor.constraint(equalTo: topAnchor), + slotView.bottomAnchor.constraint(equalTo: bottomAnchor), + slotView.leadingAnchor.constraint(equalTo: leadingAnchor), + slotView.trailingAnchor.constraint(equalTo: trailingAnchor), + ] + NSLayoutConstraint.activate(localInlineSlotConstraints) + localInlineSlotView = slotView + return slotView + } + + func setLocalInlineSlotHidden(_ hidden: Bool) { + localInlineSlotView?.isHidden = hidden + } + + func clearLocalInlineCallbacks() { + onPreferredHostedInspectorWidthChanged = nil + localInlineSlotView?.onHostedInspectorLayout = nil + } + + func prepareForWindowPortalHosting() { + hostedInspectorDockConfigurationSyncWorkItem?.cancel() + hostedInspectorDockConfigurationSyncWorkItem = nil + deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView) + hostedInspectorFrontendWebView = nil + } + + func releaseHostedWebViewConstraints() { + NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebViewConstraints = [] + hostedWebView = nil + } + + func pinHostedWebView(_ webView: WKWebView, in container: NSView) { + guard webView.superview === container || webView.isDescendant(of: container) else { return } + + let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview( + in: container, + primaryWebView: webView + ) + let needsPlainWebViewFrameReset = + webView.superview === container && + !hasCompanionWKSubviews && + Self.frameDiffersFromBounds(webView.frame, bounds: container.bounds) + let needsFrameHosting = + hostedWebView !== webView || + !hostedWebViewConstraints.isEmpty || + needsPlainWebViewFrameReset || + !webView.translatesAutoresizingMaskIntoConstraints || + webView.autoresizingMask != [.width, .height] + guard needsFrameHosting else { + needsLayout = true + layoutSubtreeIfNeeded() + return + } + + NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebViewConstraints = [] + hostedWebView = webView + + // WebKit's attached inspector does not reliably dock into a constraint-managed + // WKWebView hierarchy on macOS. Host the moved webview with autoresizing and + // preserve WebKit-managed split frames when docked DevTools siblings exist. + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + if webView.superview === container && !hasCompanionWKSubviews { + webView.frame = container.bounds + } + needsLayout = true + layoutSubtreeIfNeeded() + } + + private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(frame.minX - bounds.minX) > epsilon || + abs(frame.minY - bounds.minY) > epsilon || + abs(frame.width - bounds.width) > epsilon || + abs(frame.height - bounds.height) > epsilon + } + + private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool { + var stack = host.subviews.filter { $0 !== primaryWebView } + while let current = stack.popLast() { + if current.isDescendant(of: primaryWebView) { + continue + } + if String(describing: type(of: current)).contains("WK") { + return true + } + stack.append(contentsOf: current.subviews) + } + return false + } + + private func ensureHostedInspectorSideDockContainerView() -> HostedInspectorSideDockContainerView { + if let hostedInspectorSideDockContainerView, + hostedInspectorSideDockContainerView.superview === self { + hostedInspectorSideDockContainerView.isHidden = false + return hostedInspectorSideDockContainerView + } + + let containerView = HostedInspectorSideDockContainerView(frame: bounds) + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView, positioned: .above, relativeTo: localInlineSlotView) + hostedInspectorSideDockConstraints = [ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + ] + NSLayoutConstraint.activate(hostedInspectorSideDockConstraints) + hostedInspectorSideDockContainerView = containerView + return containerView + } + + private func moveHostedInspectorSubviewIfNeeded(_ view: NSView, to container: NSView) { + guard view.superview !== container else { return } + let frameInWindow = view.superview?.convert(view.frame, to: nil) ?? convert(view.frame, to: nil) + view.removeFromSuperview() + container.addSubview(view, positioned: .above, relativeTo: nil) + view.frame = container.convert(frameInWindow, from: nil) + } + + private func isHostedInspectorSideDockActive() -> Bool { + guard let hostedInspectorSideDockContainerView, + let hostedInspectorSideDockPageView, + let hostedInspectorSideDockInspectorView else { + return false + } + return hostedInspectorSideDockPageView.superview === hostedInspectorSideDockContainerView && + hostedInspectorSideDockInspectorView.superview === hostedInspectorSideDockContainerView + } + + private func isHostedInspectorSideDockHit(_ hit: HostedInspectorDividerHit) -> Bool { + guard let hostedInspectorSideDockContainerView else { return false } + return hit.containerView === hostedInspectorSideDockContainerView + } + + private func activateHostedInspectorSideDockIfNeeded(using hit: HostedInspectorDividerHit) { + let containerView = ensureHostedInspectorSideDockContainerView() + moveHostedInspectorSubviewIfNeeded(hit.pageView, to: containerView) + moveHostedInspectorSubviewIfNeeded(hit.inspectorView, to: containerView) + hostedInspectorSideDockPageView = hit.pageView + hostedInspectorSideDockInspectorView = hit.inspectorView + hostedInspectorSideDockDockSide = hit.dockSide + layoutHostedInspectorSideDockIfNeeded(reason: "sideDock.activate") + } + + @discardableResult + func promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() -> Bool { + guard !isHostedInspectorSideDockActive(), + let slotView = localInlineSlotView, + let hit = hostedInspectorDividerCandidateUsingKnownWebViews(in: slotView) else { + return false + } + + // The inspector frontend sometimes reports its dock configuration a tick + // late after local-inline reattach. Promote the visible left/right split + // immediately so drag routing stays symmetric on both dock sides. + activateHostedInspectorSideDockIfNeeded(using: hit) + return isHostedInspectorSideDockActive() + } + + private func deactivateHostedInspectorSideDockIfNeeded(reparentTo slotView: WindowBrowserSlotView?) { + guard let slotView, + let pageView = hostedInspectorSideDockPageView, + let inspectorView = hostedInspectorSideDockInspectorView else { + hostedInspectorSideDockPageView = nil + hostedInspectorSideDockInspectorView = nil + hostedInspectorSideDockDockSide = nil + hostedInspectorSideDockContainerView?.isHidden = true + return + } + + moveHostedInspectorSubviewIfNeeded(pageView, to: slotView) + moveHostedInspectorSubviewIfNeeded(inspectorView, to: slotView) + hostedInspectorSideDockPageView = nil + hostedInspectorSideDockInspectorView = nil + hostedInspectorSideDockDockSide = nil + hostedInspectorSideDockContainerView?.isHidden = true + } + + private func layoutHostedInspectorSideDockIfNeeded(reason: String) { + guard let containerView = hostedInspectorSideDockContainerView, + let pageView = hostedInspectorSideDockPageView, + let inspectorView = hostedInspectorSideDockInspectorView, + let dockSide = hostedInspectorSideDockDockSide else { + return + } + let preferredWidth = resolvedPreferredHostedInspectorWidth(in: containerView.bounds) ?? max(0, inspectorView.frame.width) + _ = applyHostedInspectorDividerWidth( + preferredWidth, + to: HostedInspectorDividerHit( + containerView: containerView, + pageView: pageView, + inspectorView: inspectorView, + dockSide: dockSide + ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: reason + ) + } + + func normalizeHostedInspectorLayoutIfNeeded(reason: String) { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).adaptive") { + return + } + _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if isHostedInspectorSideDockActive() { + layoutHostedInspectorSideDockIfNeeded(reason: reason) + } else if !hasStoredHostedInspectorWidthPreference { + captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason) + } + } + + private func shouldForceHostedInspectorBottomDock(using hit: HostedInspectorDividerHit) -> Bool { + let containerWidth = max(0, hit.containerView.bounds.width) + guard containerWidth > 1 else { return false } + + let currentInspectorWidth = max(0, hit.inspectorView.frame.width) + let currentPageWidth = max(0, hit.pageView.frame.width) + let remainingPageWidth = max(0, containerWidth - max(Self.minimumHostedInspectorWidth, currentInspectorWidth)) + let effectivePageWidth = min(currentPageWidth, remainingPageWidth) + + return effectivePageWidth < Self.minimumHostedInspectorPageWidthForSideDock + } + + @discardableResult + private func requestAdaptiveHostedInspectorBottomDock(reason: String) -> Bool { + let now = Date() + if let adaptiveBottomDockRequestCooldownDeadline, adaptiveBottomDockRequestCooldownDeadline > now { + return true + } + guard let hostedInspectorFrontendWebView else { return false } + + adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown) + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: reason) +#if DEBUG + dlog( + "browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " + + "host=\(Self.debugObjectID(self)) bounds=\(Self.debugRect(bounds))" + ) +#endif + hostedInspectorFrontendWebView.evaluateJavaScript( + "typeof WI !== 'undefined' ? WI._dockBottom() : null" + ) { [weak self] _, _ in + self?.scheduleHostedInspectorDockConfigurationSync( + reason: "\(reason).adaptiveBottomDock" + ) + } + return true + } + + @discardableResult + private func enforceAdaptiveBottomDockIfNeeded(reason: String) -> Bool { + guard let hit = hostedInspectorDividerCandidate(), + shouldForceHostedInspectorBottomDock(using: hit) else { + return false + } + recordHostedInspectorSideDockWidth(hit.inspectorView.frame.width) + return requestAdaptiveHostedInspectorBottomDock(reason: reason) + } + + fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) { + hostedInspectorDockConfigurationSyncWorkItem?.cancel() + guard hostedInspectorFrontendWebView != nil else { return } + let workItem = DispatchWorkItem { [weak self] in + self?.syncHostedInspectorDockConfiguration(reason: reason) + } + hostedInspectorDockConfigurationSyncWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + } + + private func syncHostedInspectorDockConfiguration(reason: String) { + hostedInspectorDockConfigurationSyncWorkItem = nil + guard let hostedInspectorFrontendWebView else { return } + hostedInspectorFrontendWebView.evaluateJavaScript( + "typeof WI === 'undefined' ? null : WI.dockConfiguration" + ) { [weak self] result, _ in + self?.applyHostedInspectorDockConfiguration(result as? String, reason: reason) + } + } + + private func applyHostedInspectorDockConfiguration(_ dockConfiguration: String?, reason: String) { + switch dockConfiguration { + case "left": + hostedInspectorSideDockDockSide = .leading + if isHostedInspectorSideDockActive() { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockLeft") { + return + } + layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockLeft") + } else if let slotView = localInlineSlotView, + let hit = hostedInspectorDividerCandidate(in: slotView), + hit.dockSide == .leading { + if shouldForceHostedInspectorBottomDock(using: hit) { + _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockLeft") + return + } + activateHostedInspectorSideDockIfNeeded(using: hit) + } + case "right": + hostedInspectorSideDockDockSide = .trailing + if isHostedInspectorSideDockActive() { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockRight") { + return + } + layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockRight") + } else if let slotView = localInlineSlotView, + let hit = hostedInspectorDividerCandidate(in: slotView), + hit.dockSide == .trailing { + if shouldForceHostedInspectorBottomDock(using: hit) { + _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockRight") + return + } + activateHostedInspectorSideDockIfNeeded(using: hit) + } + default: + adaptiveBottomDockRequestCooldownDeadline = nil + if isHostedInspectorSideDockActive() { + deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView) + if dockConfiguration == "bottom" { + hostedInspectorFrontendWebView?.evaluateJavaScript( + "typeof WI !== 'undefined' ? WI._dockBottom() : null", + completionHandler: nil + ) + } + } + } + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "\(reason).dockConfiguration") + } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + if window == nil { + clearActiveDividerCursor(restoreArrow: false) + } else { + scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToWindow") + scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToWindow") + } + window?.invalidateCursorRects(for: self) onDidMoveToWindow?() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow") +#endif } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() - onGeometryChanged?() + scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToSuperview") + scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToSuperview") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview") +#endif } override func layout() { super.layout() - onGeometryChanged?() + _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") { + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "layout") +#endif + return + } + if let previousSize = lastHostedInspectorLayoutBoundsSize, + Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) { + // Origin-only frame churn is common while the surrounding split layout + // settles. Reapplying the side-docked inspector at the same size fights + // WebKit's own dock layout and shows up as visible flicker. + if !isHostedInspectorSideDockActive() && + !isHostedInspectorDividerDragActive && + !hasStoredHostedInspectorWidthPreference { + captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize") + } + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout.sameSize") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "layout") +#endif + return + } + lastHostedInspectorLayoutBoundsSize = bounds.size + if isHostedInspectorSideDockActive() { + layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock") + } else if !hasStoredHostedInspectorWidthPreference { + captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout") + } + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout") + scheduleHostedInspectorDockConfigurationSync(reason: "layout") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "layout") +#endif } override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) - onGeometryChanged?() + window?.invalidateCursorRects(for: self) + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin") +#endif } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) - onGeometryChanged?() + window?.invalidateCursorRects(for: self) + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize") +#endif + } + + override func resetCursorRects() { + super.resetCursorRects() + guard let hostedInspectorHit = hostedInspectorDividerCandidate() else { return } + let clipped = hostedInspectorDividerHitRect(for: hostedInspectorHit).intersection(bounds) + guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { return } + addCursorRect(clipped, cursor: NSCursor.resizeLeftRight) + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let options: NSTrackingArea.Options = [ + .inVisibleRect, + .activeAlways, + .cursorUpdate, + .mouseMoved, + .mouseEnteredAndExited, + .enabledDuringMouseDrag, + ] + let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + addTrackingArea(next) + trackingArea = next + super.updateTrackingAreas() + } + + override func cursorUpdate(with event: NSEvent) { + updateDividerCursor(at: convert(event.locationInWindow, from: nil)) + } + + override func mouseMoved(with event: NSEvent) { + updateDividerCursor(at: convert(event.locationInWindow, from: nil)) + } + + override func mouseExited(with event: NSEvent) { + clearActiveDividerCursor(restoreArrow: true) } override func hitTest(_ point: NSPoint) -> NSView? { - if shouldPassThroughToSidebarResizer(at: point) { + let hostedInspectorHit = hostedInspectorDividerHit(at: point) + updateDividerCursor(at: point, hostedInspectorHit: hostedInspectorHit) + let passThrough = shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: hostedInspectorHit) + if passThrough { +#if DEBUG + debugLogHitTest(stage: "hitTest.pass", point: point, passThrough: true, hitView: nil) +#endif return nil } - return super.hitTest(point) + if let hostedInspectorHit { + let isSideDockHit = isHostedInspectorSideDockHit(hostedInspectorHit) + if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) { +#if DEBUG + debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit) +#endif + if !isSideDockHit || + (nativeHit !== hostedInspectorHit.inspectorView && + !hostedInspectorHit.inspectorView.isDescendant(of: nativeHit)) { + return nativeHit + } + } +#if DEBUG + debugLogHitTest( + stage: isSideDockHit ? "hitTest.hostedInspectorManual" : "hitTest.hostedInspectorFallback", + point: point, + passThrough: false, + hitView: hostedInspectorHit.inspectorView + ) +#endif + return isSideDockHit ? self : hostedInspectorHit.inspectorView + } + let hit = super.hitTest(point) +#if DEBUG + debugLogHitTest(stage: "hitTest.result", point: point, passThrough: false, hitView: hit) +#endif + return hit } - private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + guard let hostedInspectorHit = hostedInspectorDividerHit(at: point), + isHostedInspectorSideDockHit(hostedInspectorHit) else { + super.mouseDown(with: event) + return + } + + hostedInspectorReapplyWorkItem?.cancel() + isHostedInspectorDividerDragActive = true + hostedInspectorDividerDrag = HostedInspectorDividerDragState( + containerView: hostedInspectorHit.containerView, + pageView: hostedInspectorHit.pageView, + inspectorView: hostedInspectorHit.inspectorView, + dockSide: hostedInspectorHit.dockSide, + initialWindowX: event.locationInWindow.x, + initialPageFrame: hostedInspectorHit.pageView.frame, + initialInspectorFrame: hostedInspectorHit.inspectorView.frame + ) +#if DEBUG + debugLogHostedInspectorFrames(stage: "drag.start", point: point, hit: hostedInspectorHit) +#endif + } + + override func mouseDragged(with event: NSEvent) { + guard let dragState = hostedInspectorDividerDrag else { + super.mouseDragged(with: event) + return + } + + let containerBounds = dragState.containerView.bounds + let minimumInspectorWidth = Self.minimumHostedInspectorWidth + let initialDividerX = dragState.dockSide.dividerX( + pageFrame: dragState.initialPageFrame, + inspectorFrame: dragState.initialInspectorFrame + ) + let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX) + let clampedDividerX = dragState.dockSide.clampedDividerX( + proposedDividerX, + containerBounds: containerBounds, + pageFrame: dragState.initialPageFrame, + minimumInspectorWidth: minimumInspectorWidth + ) + let inspectorWidth = dragState.dockSide.inspectorWidth( + forDividerX: clampedDividerX, + in: containerBounds + ) + recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds) + _ = applyHostedInspectorDividerWidth( + inspectorWidth, + to: HostedInspectorDividerHit( + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide + ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: "drag" + ) +#if DEBUG + debugLogHostedInspectorFrames( + stage: "drag.update", + point: convert(event.locationInWindow, from: nil), + hit: HostedInspectorDividerHit( + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide + ) + ) +#endif + updateDividerCursor( + at: convert(event.locationInWindow, from: nil), + hostedInspectorHit: HostedInspectorDividerHit( + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide + ) + ) + } + + override func mouseUp(with event: NSEvent) { + let finalDragState = hostedInspectorDividerDrag + hostedInspectorDividerDrag = nil + isHostedInspectorDividerDragActive = false + updateDividerCursor(at: convert(event.locationInWindow, from: nil)) + if let finalDragState { +#if DEBUG + debugLogHostedInspectorFrames( + stage: "drag.end", + point: convert(event.locationInWindow, from: nil), + hit: HostedInspectorDividerHit( + containerView: finalDragState.containerView, + pageView: finalDragState.pageView, + inspectorView: finalDragState.inspectorView, + dockSide: finalDragState.dockSide + ) + ) +#endif + layoutHostedInspectorSideDockIfNeeded(reason: "drag.end") + } + super.mouseUp(with: event) + } + + private func shouldPassThroughToSidebarResizer( + at point: NSPoint, + hostedInspectorHit: HostedInspectorDividerHit? = nil + ) -> Bool { + if hostedInspectorHit != nil { + return false + } // Pass through a narrow leading-edge band so the shared sidebar divider // handle can receive hover/click even when WKWebView is attached here. // Keeping this deterministic avoids flicker from dynamic left-edge scans. @@ -2823,6 +4762,400 @@ struct WebViewRepresentable: NSViewRepresentable { let hostRectInContent = contentView.convert(bounds, from: self) return hostRectInContent.minX > 1 } + + private func updateDividerCursor( + at point: NSPoint, + hostedInspectorHit: HostedInspectorDividerHit? = nil + ) { + let resolvedHostedInspectorHit = hostedInspectorHit ?? hostedInspectorDividerHit(at: point) + if shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: resolvedHostedInspectorHit) { + clearActiveDividerCursor(restoreArrow: false) + return + } + guard resolvedHostedInspectorHit != nil else { + clearActiveDividerCursor(restoreArrow: true) + return + } + activeDividerCursorKind = .vertical + NSCursor.resizeLeftRight.set() + } + + private func clearActiveDividerCursor(restoreArrow: Bool) { + guard activeDividerCursorKind != nil else { return } + window?.invalidateCursorRects(for: self) + activeDividerCursorKind = nil + if restoreArrow { + NSCursor.arrow.set() + } + } + + private func nativeHostedInspectorHit( + at point: NSPoint, + hostedInspectorHit: HostedInspectorDividerHit + ) -> NSView? { + guard let nativeHit = super.hitTest(point), nativeHit !== self else { return nil } + if nativeHit === hostedInspectorHit.pageView || + nativeHit.isDescendant(of: hostedInspectorHit.pageView) { + return nil + } + if nativeHit === hostedInspectorHit.inspectorView || + nativeHit.isDescendant(of: hostedInspectorHit.inspectorView) { + return nativeHit + } + if hostedInspectorHit.inspectorView.isDescendant(of: nativeHit), + !(hostedInspectorHit.pageView === nativeHit || hostedInspectorHit.pageView.isDescendant(of: nativeHit)) { + return nativeHit + } + return nil + } + + private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? { + guard let hit = hostedInspectorDividerCandidate(), + hostedInspectorDividerHitRect(for: hit).contains(point) else { + return nil + } + return hit + } + + private func hostedInspectorDividerCandidate() -> HostedInspectorDividerHit? { + hostedInspectorDividerCandidate(in: self) + } + + private func hostedInspectorDividerCandidate(in root: NSView) -> HostedInspectorDividerHit? { + if let preferredHit = hostedInspectorDividerCandidateUsingKnownWebViews(in: root) { + return preferredHit + } + + let inspectorCandidates = Self.visibleDescendants(in: root) + .filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) } + .sorted { lhs, rhs in + let lhsFrame = root.convert(lhs.bounds, from: lhs) + let rhsFrame = root.convert(rhs.bounds, from: rhs) + return lhsFrame.minX < rhsFrame.minX + } + + var bestHit: HostedInspectorDividerHit? + var bestScore = -CGFloat.greatestFiniteMagnitude + + for inspectorCandidate in inspectorCandidates { + guard let candidate = hostedInspectorDividerCandidate(in: root, startingAt: inspectorCandidate) else { + continue + } + let score = hostedInspectorDividerCandidateScore(candidate) + if score > bestScore { + bestScore = score + bestHit = candidate + } + } + + return bestHit + } + + private func hostedInspectorDividerCandidateUsingKnownWebViews(in root: NSView) -> HostedInspectorDividerHit? { + guard let pageLeaf = hostedWebView, + let inspectorLeaf = hostedInspectorFrontendWebView, + pageLeaf.isDescendant(of: root), + inspectorLeaf.isDescendant(of: root), + Self.isVisibleHostedInspectorCandidate(inspectorLeaf) else { + return nil + } + return hostedInspectorDividerCandidate( + in: root, + pageLeaf: pageLeaf, + inspectorLeaf: inspectorLeaf + ) + } + + private func hostedInspectorDividerCandidate( + in root: NSView, + pageLeaf: NSView, + inspectorLeaf: NSView + ) -> HostedInspectorDividerHit? { + var currentInspector: NSView? = inspectorLeaf + + while let inspectorView = currentInspector, inspectorView !== root { + guard let containerView = inspectorView.superview else { break } + guard containerView === root || containerView.isDescendant(of: root) else { + currentInspector = containerView + continue + } + guard let pageView = Self.directChild(of: containerView, containing: pageLeaf) else { + currentInspector = containerView + continue + } + guard pageView !== inspectorView, + Self.isVisibleHostedInspectorSiblingCandidate(pageView), + Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame) > 8, + let dockSide = HostedInspectorDockSide.resolve( + pageFrame: pageView.frame, + inspectorFrame: inspectorView.frame + ) else { + currentInspector = containerView + continue + } + return HostedInspectorDividerHit( + containerView: containerView, + pageView: pageView, + inspectorView: inspectorView, + dockSide: dockSide + ) + } + + return nil + } + + private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect { + let pageFrame = convert(hit.pageView.bounds, from: hit.pageView) + let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView) + return hit.dockSide.dividerHitRect( + in: bounds, + pageFrame: pageFrame, + inspectorFrame: inspectorFrame, + expansion: Self.hostedInspectorDividerHitExpansion + ) + } + + private func hostedInspectorDividerCandidate(in root: NSView, startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? { + var current: NSView? = inspectorLeaf + var bestHit: HostedInspectorDividerHit? + + while let inspectorView = current, inspectorView !== root { + guard let containerView = inspectorView.superview else { break } + + let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in + guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil } + guard candidate !== inspectorView else { return nil } + guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { + return nil + } + guard let dockSide = HostedInspectorDockSide.resolve( + pageFrame: candidate.frame, + inspectorFrame: inspectorView.frame + ) else { + return nil + } + return (view: candidate, dockSide: dockSide) + } + + if let pageCandidate = pageCandidates.max(by: { + hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView) + < hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView) + }) { + bestHit = HostedInspectorDividerHit( + containerView: containerView, + pageView: pageCandidate.view, + inspectorView: inspectorView, + dockSide: pageCandidate.dockSide + ) + } + + current = containerView + } + + return bestHit + } + + private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat { + let pageFrame = convert(hit.pageView.bounds, from: hit.pageView) + let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView) + let overlap = Self.verticalOverlap(between: pageFrame, and: inspectorFrame) + let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX) + return (overlap * 1_000) + coverageWidth + pageFrame.width + } + + private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat { + let overlap = Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame) + let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX) + return (overlap * 1_000) + coverageWidth + pageView.frame.width + } + + fileprivate func scheduleHostedInspectorDividerReapply(reason: String) { + hostedInspectorReapplyWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.hostedInspectorReapplyWorkItem = nil + _ = self.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if self.isHostedInspectorSideDockActive() { + self.reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: reason) + } else if !self.hasStoredHostedInspectorWidthPreference { + self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason) + } + } + hostedInspectorReapplyWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + } + + private func captureHostedInspectorPreferredWidthFromCurrentLayout(reason: String) { + guard !isApplyingHostedInspectorLayout else { return } + guard !isHostedInspectorDividerDragActive else { return } + guard let hit = hostedInspectorDividerCandidate() else { +#if DEBUG + if !hasLoggedMissingHostedInspectorCandidate { + hasLoggedMissingHostedInspectorCandidate = true + let preferredWidthDesc = preferredHostedInspectorWidth.map { + String(format: "%.1f", $0) + } ?? "nil" + dlog( + "browser.panel.hostedInspector stage=\(reason).captureMissingCandidate " + + "host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)" + ) + } +#endif + return + } + + let inspectorWidth = max(0, hit.inspectorView.frame.width) + guard inspectorWidth > 1 else { return } + recordHostedInspectorSideDockWidth(inspectorWidth) + let currentFraction: CGFloat? = { + guard hit.containerView.bounds.width > 0 else { return nil } + return inspectorWidth / hit.containerView.bounds.width + }() + let widthMatches = preferredHostedInspectorWidth.map { + abs($0 - inspectorWidth) <= 0.5 + } ?? false + let fractionMatches: Bool = { + switch (preferredHostedInspectorWidthFraction, currentFraction) { + case (nil, nil): + return true + case let (lhs?, rhs?): + return abs(lhs - rhs) <= 0.001 + default: + return false + } + }() + guard !(widthMatches && fractionMatches) else { return } + +#if DEBUG + hasLoggedMissingHostedInspectorCandidate = false +#endif + recordPreferredHostedInspectorWidth( + inspectorWidth, + containerBounds: hit.containerView.bounds + ) + } + + private func reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: String) { + guard !isApplyingHostedInspectorLayout else { return } + guard let hit = hostedInspectorDividerCandidate() else { return } + guard isHostedInspectorSideDockHit(hit) else { return } + guard let preferredWidth = resolvedPreferredHostedInspectorWidth(in: hit.containerView.bounds) else { + return + } + let currentInspectorWidth = max(0, hit.inspectorView.frame.width) + guard abs(currentInspectorWidth - preferredWidth) > 0.5 else { return } + _ = applyHostedInspectorDividerWidth( + preferredWidth, + to: hit, + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: reason + ) + } + + @discardableResult + private func applyHostedInspectorDividerWidth( + _ preferredWidth: CGFloat, + to hit: HostedInspectorDividerHit, + minimumInspectorWidth: CGFloat, + reason: String + ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { + let containerBounds = hit.containerView.bounds + let nextFrames = hit.dockSide.resizedFrames( + preferredWidth: preferredWidth, + in: containerBounds, + pageFrame: hit.pageView.frame, + inspectorFrame: hit.inspectorView.frame, + minimumInspectorWidth: minimumInspectorWidth + ) + let pageFrame = nextFrames.pageFrame + let inspectorFrame = nextFrames.inspectorFrame + + let oldPageFrame = hit.pageView.frame + let oldInspectorFrame = hit.inspectorView.frame + let pageChanged = !Self.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5) + let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5) + guard pageChanged || inspectorChanged else { + return (pageFrame, inspectorFrame) + } + recordHostedInspectorSideDockWidth(inspectorFrame.width) + + isApplyingHostedInspectorLayout = true + CATransaction.begin() + CATransaction.setDisableActions(true) + hit.pageView.frame = pageFrame + hit.inspectorView.frame = inspectorFrame + CATransaction.commit() + isApplyingHostedInspectorLayout = false + + hit.pageView.needsDisplay = true + hit.pageView.setNeedsDisplay(hit.pageView.bounds) + hit.inspectorView.needsDisplay = true + hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds) + hit.containerView.needsDisplay = true + hit.containerView.setNeedsDisplay(hit.containerView.bounds) + if let localInlineSlotView { + localInlineSlotView.needsDisplay = true + localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds) + } + needsDisplay = true + setNeedsDisplay(bounds) + + let isLiveDrag = reason == "drag" +#if DEBUG + dlog( + "browser.panel.hostedInspector stage=\(reason).reapply " + + "host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " + + "liveDrag=\(isLiveDrag ? 1 : 0) " + + "pageChanged=\(pageChanged ? 1 : 0) inspectorChanged=\(inspectorChanged ? 1 : 0) " + + "oldPage=\(Self.debugRect(oldPageFrame)) oldInspector=\(Self.debugRect(oldInspectorFrame)) " + + "container=\(Self.debugObjectID(hit.containerView)) " + + "pageFrame=\(Self.debugRect(pageFrame)) inspectorFrame=\(Self.debugRect(inspectorFrame))" + ) +#endif + return (pageFrame, inspectorFrame) + } + + private static func visibleDescendants(in root: NSView) -> [NSView] { + var descendants: [NSView] = [] + var stack = Array(root.subviews.reversed()) + while let view = stack.popLast() { + descendants.append(view) + stack.append(contentsOf: view.subviews.reversed()) + } + return descendants + } + + private static func directChild(of container: NSView, containing descendant: NSView) -> NSView? { + var current: NSView? = descendant + var directChild: NSView? + while let view = current, view !== container { + directChild = view + current = view.superview + } + guard current === container else { return nil } + return directChild + } + + fileprivate static func isInspectorView(_ view: NSView) -> Bool { + String(describing: type(of: view)).contains("WKInspector") + } + + fileprivate static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool { + !view.isHidden && + view.alphaValue > 0 && + view.frame.width > 1 && + view.frame.height > 1 + } + + private static func isVisibleHostedInspectorSiblingCandidate(_ view: NSView) -> Bool { + !view.isHidden && + view.alphaValue > 0 && + view.frame.height > 1 + } + + private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { + max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) + } } #if DEBUG @@ -2921,62 +5254,384 @@ struct WebViewRepresentable: NSViewRepresentable { guard let host = host as? HostContainerView else { return } host.onDidMoveToWindow = nil host.onGeometryChanged = nil + host.clearLocalInlineCallbacks() } - private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { - guard let host = nsView as? HostContainerView else { return } + private static func localInlineTransferRoot(for webView: WKWebView) -> NSView? { + var current = webView.superview + var last: NSView? + while let view = current { + if view is WindowBrowserSlotView { + return view + } + if view is HostContainerView { + break + } + last = view + current = view.superview + } + return last ?? webView.superview + } + + private static func moveWebKitRelatedSubviewsIntoHostIfNeeded( + from sourceSuperview: NSView, + to container: WindowBrowserSlotView, + primaryWebView: WKWebView, + reason: String + ) { + guard sourceSuperview !== container else { return } + let relatedSubviews = sourceSuperview.subviews.filter { view in + if view === primaryWebView { return true } + let className = String(describing: type(of: view)) + guard className.contains("WK") else { return false } + if className.contains("WKInspector") { + return !view.isHidden && view.alphaValue > 0 && view.frame.width > 1 && view.frame.height > 1 + } + return true + } + guard !relatedSubviews.isEmpty else { return } + let preserveSlotLocalFrames = sourceSuperview is WindowBrowserSlotView + let sourceSlotBoundsSize = sourceSuperview.bounds.size +#if DEBUG + dlog( + "browser.localHost.reparent.batch reason=\(reason) source=\(Self.objectID(sourceSuperview)) " + + "container=\(Self.objectID(container)) count=\(relatedSubviews.count) " + + "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: container)))" + ) +#endif + for view in relatedSubviews { + let className = String(describing: type(of: view)) + let targetFrame: NSRect + if preserveSlotLocalFrames { + targetFrame = view.frame + } else { + let frameInWindow = sourceSuperview.convert(view.frame, to: nil) + targetFrame = container.convert(frameInWindow, from: nil) + } + view.removeFromSuperview() + container.addSubview(view, positioned: .above, relativeTo: nil) + view.frame = targetFrame +#if DEBUG + dlog( + "browser.localHost.reparent.batch.item reason=\(reason) class=\(className) " + + "view=\(Self.objectID(view))" + ) +#endif + } + if preserveSlotLocalFrames, sourceSlotBoundsSize != container.bounds.size { + container.resizeSubviews(withOldSize: sourceSlotBoundsSize) + container.needsLayout = true + container.layoutSubtreeIfNeeded() + } + } + + private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) { + // SwiftUI can keep transient replacement hosts alive off-window during split + // reparenting. Never let those hosts steal the shared portal anchor, or the + // portal will bind against an anchor with no real window and WKWebView will + // fall into a hidden/unrendered state. + guard host.window != nil else { return } + if anchorView.superview !== host { + anchorView.removeFromSuperview() + anchorView.translatesAutoresizingMaskIntoConstraints = false + host.addSubview(anchorView) + NSLayoutConstraint.activate([ + anchorView.topAnchor.constraint(equalTo: host.topAnchor), + anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor), + anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor), + anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor), + ]) + } else if anchorView.translatesAutoresizingMaskIntoConstraints { + anchorView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + anchorView.topAnchor.constraint(equalTo: host.topAnchor), + anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor), + anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor), + anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor), + ]) + } + host.layoutSubtreeIfNeeded() + } + + private func updateUsingLocalInlineHosting(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { + guard let host = nsView as? HostContainerView else { return false } + let slotView = host.ensureLocalInlineSlotView() + let isAlreadyInLocalHost = host.containsManagedLocalInlineContent(webView) + let didAttachWebViewToLocalHost = !isAlreadyInLocalHost let coordinator = context.coordinator + coordinator.desiredPortalVisibleInUI = false + coordinator.desiredPortalZPriority = 0 + coordinator.attachGeneration += 1 + + if panel.releasePortalHostIfOwned( + hostId: ObjectIdentifier(host), + reason: "localInlineHosting" + ) { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: "viewStateChanged.localInlineHosting" + ) + } + + let shouldPreserveExistingExternalLocalHost = + host.window == nil && + webView.superview != nil && + !host.containsManagedLocalInlineContent(webView) + if shouldPreserveExistingExternalLocalHost { + // Split zoom can instantiate a replacement local host before it joins a window. + // Never let that off-window host steal the live page + inspector hierarchy away + // from the currently visible local host. + host.setLocalInlineSlotHidden(true) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 +#if DEBUG + dlog( + "browser.localHost.reparent.skip web=\(Self.objectID(webView)) " + + "reason=offWindowReplacementHost super=\(Self.objectID(webView.superview)) " + + "host=\(Self.objectID(host)) slot=\(Self.objectID(slotView))" + ) + Self.logDevToolsState( + panel, + event: "localHost.skip", + generation: coordinator.attachGeneration, + retryCount: 0, + details: Self.attachContext(webView: webView, host: host) + ) +#endif + return false + } + + let preferredAttachedWidthState = panel.preferredAttachedDeveloperToolsWidthState() + host.setPreferredHostedInspectorWidth( + width: preferredAttachedWidthState.width, + widthFraction: preferredAttachedWidthState.widthFraction + ) + host.setHostedInspectorFrontendWebView(webView.cmuxInspectorFrontendWebView()) + host.onPreferredHostedInspectorWidthChanged = { [weak browserPanel = panel] width, _ in + guard let browserPanel else { return } + browserPanel.recordPreferredAttachedDeveloperToolsWidth( + width, + containerBounds: slotView.bounds + ) + } + slotView.onHostedInspectorLayout = { [weak host] _ in + host?.scheduleHostedInspectorDividerReapply(reason: "slot.layout") + host?.scheduleHostedInspectorDockConfigurationSync(reason: "slot.layout") + } + + if didAttachWebViewToLocalHost { + if let sourceSuperview = Self.localInlineTransferRoot(for: webView) { + Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( + from: sourceSuperview, + to: slotView, + primaryWebView: webView, + reason: "attachLocalHost" + ) + } else { + slotView.addSubview(webView, positioned: .above, relativeTo: nil) + } + } + + slotView.isHidden = false + host.pinHostedWebView( + webView, + in: host.currentHostedWebViewContainer(preferredSlotView: slotView) + ) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 + if didAttachWebViewToLocalHost { + panel.restoreDeveloperToolsAfterAttachIfNeeded() + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() + slotView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + host.normalizeHostedInspectorLayoutIfNeeded(reason: "localInline.update.immediate") + host.scheduleHostedInspectorDividerReapply(reason: "localInline.update.sync") + DispatchQueue.main.async { [weak host, weak webView] in + guard let host, let webView else { return } + host.setHostedInspectorFrontendWebView(webView.cmuxInspectorFrontendWebView()) + host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update.async") + } + } else { + host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update") + } + +#if DEBUG + Self.logDevToolsState( + panel, + event: "localHost.update", + generation: coordinator.attachGeneration, + retryCount: 0, + details: Self.attachContext(webView: webView, host: host) + ) +#endif + return true + } + + private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { + guard let host = nsView as? HostContainerView else { return false } + host.prepareForWindowPortalHosting() + host.setLocalInlineSlotHidden(true) + host.releaseHostedWebViewConstraints() + + let coordinator = context.coordinator + let paneDropContext = currentPaneDropContext() + let isCurrentPaneOwner = paneDropContext?.paneId.id == paneId.id + let hostId = ObjectIdentifier(host) let previousVisible = coordinator.desiredPortalVisibleInUI let previousZPriority = coordinator.desiredPortalZPriority - coordinator.desiredPortalVisibleInUI = shouldAttachWebView + coordinator.desiredPortalVisibleInUI = shouldAttachWebView && isCurrentPaneOwner coordinator.desiredPortalZPriority = portalZPriority coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration + let activePaneDropContext = coordinator.desiredPortalVisibleInUI ? paneDropContext : nil + let activeSearchOverlay = coordinator.desiredPortalVisibleInUI ? searchOverlay : nil + let portalAnchorView = panel.portalAnchorView + let portalHideReason = !isCurrentPaneOwner ? "lostPaneOwnership" : "hidden" + let didReleasePortalHost: Bool + if !shouldAttachWebView || !isCurrentPaneOwner { + didReleasePortalHost = panel.releasePortalHostIfOwned( + hostId: hostId, + reason: portalHideReason + ) + // Only the host that currently owns the portal is allowed to hide it. + // Older keep-alive hosts can still receive updates after a new owner binds. + if didReleasePortalHost { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: "viewStateChanged.\(portalHideReason)" + ) + } + } else { + didReleasePortalHost = false + } + let portalHostAccepted = + shouldAttachWebView && + isCurrentPaneOwner && + panel.claimPortalHost( + hostId: hostId, + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "update" + ) +#if DEBUG + if !isCurrentPaneOwner && (shouldAttachWebView || host.window != nil) { + dlog( + "browser.portal.owner.skip panel=\(panel.id.uuidString.prefix(5)) " + + "viewPane=\(paneId.id.uuidString.prefix(5)) " + + "currentPane=\(paneDropContext?.paneId.id.uuidString.prefix(5) ?? "nil") " + + "host=\(Self.objectID(host)) hostInWin=\(host.window != nil ? 1 : 0) " + + "released=\(didReleasePortalHost ? 1 : 0)" + ) + } +#endif + if host.window != nil, portalHostAccepted { + Self.installPortalAnchorView(portalAnchorView, in: host) + } - host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in - guard let host, let webView, let coordinator else { return } + host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in + guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } guard coordinator.attachGeneration == generation else { return } + guard currentPaneDropContext()?.paneId.id == paneId.id else { return } + guard browserPanel.claimPortalHost( + hostId: ObjectIdentifier(host), + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "didMoveToWindow" + ) else { return } guard host.window != nil else { return } + Self.installPortalAnchorView(portalAnchorView, in: host) BrowserWindowPortalRegistry.bind( webView: webView, - to: host, + to: portalAnchorView, visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) coordinator.lastPortalHostId = ObjectIdentifier(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } - host.onGeometryChanged = { [weak host, weak coordinator] in - guard let host, let coordinator else { return } + host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in + guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } guard coordinator.attachGeneration == generation else { return } - guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return } - BrowserWindowPortalRegistry.synchronizeForAnchor(host) + guard currentPaneDropContext()?.paneId.id == paneId.id else { return } + guard browserPanel.claimPortalHost( + hostId: ObjectIdentifier(host), + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "geometryChanged" + ) else { return } + guard host.window != nil else { return } + let hostId = ObjectIdentifier(host) + Self.installPortalAnchorView(portalAnchorView, in: host) + if coordinator.lastPortalHostId != hostId || + !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) { + BrowserWindowPortalRegistry.bind( + webView: webView, + to: portalAnchorView, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + coordinator.lastPortalHostId = hostId + } + BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } if !shouldAttachWebView { // In portal mode we no longer detach/re-attach to preserve DevTools state. // Sync the inspector preference directly so manual closes are respected. - panel.syncDeveloperToolsPreferenceFromInspector() + panel.syncDeveloperToolsPreferenceFromInspector( + preserveVisibleIntent: panel.shouldPreserveDeveloperToolsIntentWhileDetached() + ) } - if host.window != nil { - let hostId = ObjectIdentifier(host) + if host.window != nil, portalHostAccepted { + let geometryRevision = host.geometryRevision + let portalEntryMissing = !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) let shouldBindNow = coordinator.lastPortalHostId != hostId || webView.superview == nil || + portalEntryMissing || previousVisible != shouldAttachWebView || previousZPriority != portalZPriority if shouldBindNow { + Self.installPortalAnchorView(portalAnchorView, in: host) BrowserWindowPortalRegistry.bind( webView: webView, - to: host, + to: portalAnchorView, visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) coordinator.lastPortalHostId = hostId + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - BrowserWindowPortalRegistry.synchronizeForAnchor(host) - } else { + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + if !shouldBindNow, + coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } + } else if portalHostAccepted { // Bind is deferred until host moves into a window. Keep the current // portal entry's desired state in sync so stale callbacks cannot keep // the previous anchor visible while this host is temporarily off-window. @@ -2987,6 +5642,22 @@ struct WebViewRepresentable: NSViewRepresentable { ) } + if portalHostAccepted { + BrowserWindowPortalRegistry.updateDropZoneOverlay( + for: webView, + zone: coordinator.desiredPortalVisibleInUI ? paneDropZone : nil + ) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updatePaneDropContext( + for: webView, + context: activePaneDropContext + ) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + } + panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG @@ -2994,351 +5665,41 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "portal.update", generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount, + retryCount: 0, details: Self.attachContext(webView: webView, host: host) ) #endif - } - - private static func attachWebView(_ webView: WKWebView, to host: NSView) { - // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder - // while being detached/reparented during bonsplit/SwiftUI structural updates. - if let window = webView.window { - let state = firstResponderResignState(window.firstResponder, webView: webView) - if state.needsResign { - window.makeFirstResponder(nil) - } - } - - // The target host can already be in-window while the source host is tearing down. - // Re-check against the target window too (it can differ during split churn). - if let window = host.window { - let state = firstResponderResignState(window.firstResponder, webView: webView) - if state.needsResign { - window.makeFirstResponder(nil) - } - } - - // Detach from any previous host (bonsplit/SwiftUI may rearrange views). - webView.removeFromSuperview() - host.subviews.forEach { $0.removeFromSuperview() } - host.addSubview(webView) - - // Work around WebKit bug 272474 where Inspect Element can render blank/flicker - // when WKWebView is edge-pinned using Auto Layout constraints. - webView.translatesAutoresizingMaskIntoConstraints = true - webView.autoresizingMask = [.width, .height] - webView.frame = host.bounds - - // Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out. - webView.needsLayout = true - webView.layoutSubtreeIfNeeded() - webView.needsDisplay = true - webView.displayIfNeeded() - } - - private static func scheduleAttachRetry( - _ webView: WKWebView, - panel: BrowserPanel, - to host: NSView, - coordinator: Coordinator, - generation: Int - ) { - // Don't schedule multiple overlapping retries. - guard coordinator.attachRetryWorkItem == nil else { return } - - let work = DispatchWorkItem { [weak host, weak webView] in - coordinator.attachRetryWorkItem = nil - guard let host, let webView else { return } - guard coordinator.attachGeneration == generation else { return } - - // If already attached, we're done. - if webView.superview === host { - coordinator.attachRetryCount = 0 - return - } - - // Wait until the host is actually in a window. SwiftUI can create a new container before it - // is in a window during bonsplit tree updates; moving the webview too early can be flaky. - guard host.window != nil else { - coordinator.attachRetryCount += 1 - #if DEBUG - if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 { - logDevToolsState( - panel, - event: "retry.waitingForWindow", - generation: generation, - retryCount: coordinator.attachRetryCount, - details: attachContext(webView: webView, host: host) - ) - } - #endif - // 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) { - scheduleAttachRetry( - webView, - panel: panel, - to: host, - coordinator: coordinator, - generation: generation - ) - } - } - return - } - - coordinator.attachRetryCount = 0 - #if DEBUG - logDevToolsState( - panel, - event: "retry.attach.begin", - generation: generation, - retryCount: 0, - details: attachContext(webView: webView, host: host) - ) - #endif - attachWebView(webView, to: host) - panel.restoreDeveloperToolsAfterAttachIfNeeded() - #if DEBUG - logDevToolsState( - panel, - event: "retry.attached", - generation: generation, - retryCount: 0, - details: attachContext(webView: webView, host: host) - ) - #endif - } - - coordinator.attachRetryWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) + return portalHostAccepted } func updateNSView(_ nsView: NSView, context: Context) { let webView = panel.webView - context.coordinator.panel = panel - context.coordinator.webView = webView - - let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide() - if shouldUseWindowPortal { - context.coordinator.usesWindowPortal = true - Self.clearPortalCallbacks(for: nsView) - updateUsingWindowPortal(nsView, context: context, webView: webView) - Self.applyFocus( - panel: panel, - webView: webView, - nsView: nsView, - shouldFocusWebView: shouldFocusWebView, - isPanelFocused: isPanelFocused - ) - return + let coordinator = context.coordinator + let isCurrentPaneOwner = currentPaneDropContext()?.paneId.id == paneId.id + if let previousWebView = coordinator.webView, previousWebView !== webView { + BrowserWindowPortalRegistry.detach(webView: previousWebView) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 } + coordinator.panel = panel + coordinator.webView = webView - if context.coordinator.usesWindowPortal { - BrowserWindowPortalRegistry.detach(webView: webView) - context.coordinator.usesWindowPortal = false - context.coordinator.lastPortalHostId = nil - } Self.clearPortalCallbacks(for: nsView) - - // Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left - // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce - // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. - if !shouldAttachWebView { - // Split/layout churn can briefly create an off-window phase while DevTools is open. - // Detaching here can blank inspector content even when visibility preference stays true. - if nsView.window == nil, - webView.superview != nil, - panel.shouldPreserveWebViewAttachmentDuringTransientHide() { - #if DEBUG - Self.logDevToolsState( - panel, - event: "detach.skipped.offWindowDevTools", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - #endif - return - } - - #if DEBUG - Self.logDevToolsState( - panel, - event: "detach.beforeSync", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - #endif - panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) - #if DEBUG - Self.logDevToolsState( - panel, - event: "detach.afterSync", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - #endif - context.coordinator.attachRetryWorkItem?.cancel() - context.coordinator.attachRetryWorkItem = nil - context.coordinator.attachRetryCount = 0 - context.coordinator.attachGeneration += 1 - - // Resign focus if WebKit currently owns first responder. - if let window = webView.window ?? nsView.window { - let state = Self.firstResponderResignState(window.firstResponder, webView: webView) - if state.needsResign { - #if DEBUG - Self.logDevToolsState( - panel, - event: "detach.resignFirstResponder", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags - ) - #endif - window.makeFirstResponder(nil) - } - } - - if webView.superview != nil { - webView.removeFromSuperview() - } - nsView.subviews.forEach { $0.removeFromSuperview() } - #if DEBUG - Self.logDevToolsState( - panel, - event: "detach.done", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - #endif - return - } - - if webView.superview !== nsView { - // Cancel any pending retry; we'll reschedule if needed. - context.coordinator.attachRetryWorkItem?.cancel() - context.coordinator.attachRetryWorkItem = nil - context.coordinator.attachGeneration += 1 - - if let window = webView.window ?? nsView.window { - let state = Self.firstResponderResignState(window.firstResponder, webView: webView) - if state.needsResign { - #if DEBUG - Self.logDevToolsState( - panel, - event: "attach.reparent.resignFirstResponder.begin", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags - ) - #endif - let resigned = window.makeFirstResponder(nil) - #if DEBUG - Self.logDevToolsState( - panel, - event: "attach.reparent.resignFirstResponder.end", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + " resigned=\(resigned ? 1 : 0)" - ) - #endif - } - } - - if nsView.window == nil { - // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI - // can create containers that are never inserted into the window. - if panel.shouldPreserveWebViewAttachmentDuringTransientHide() { - panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "attach.defer.offWindow") - #if DEBUG - Self.logDevToolsState( - panel, - event: "attach.defer.requestRefresh", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - #endif - } - #if DEBUG - Self.logDevToolsState( - panel, - event: "attach.defer.offWindow", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - #endif - Self.scheduleAttachRetry( - webView, - panel: panel, - to: nsView, - coordinator: context.coordinator, - generation: context.coordinator.attachGeneration - ) - } else { - #if DEBUG - Self.logDevToolsState( - panel, - event: "attach.immediate.begin", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - #endif - Self.attachWebView(webView, to: nsView) - panel.restoreDeveloperToolsAfterAttachIfNeeded() - #if DEBUG - Self.logDevToolsState( - panel, - event: "attach.immediate", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - #endif - } - } else { - // Already attached; no need for any pending retry. - context.coordinator.attachRetryWorkItem?.cancel() - context.coordinator.attachRetryWorkItem = nil - context.coordinator.attachRetryCount = 0 - context.coordinator.attachGeneration += 1 - let hadPendingRefresh = panel.hasPendingDeveloperToolsRefreshAfterAttach() - panel.restoreDeveloperToolsAfterAttachIfNeeded() - #if DEBUG - if hadPendingRefresh { - Self.logDevToolsState( - panel, - event: "attach.alreadyAttached.consumePendingRefresh", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - } - Self.logDevToolsState( - panel, - event: "attach.alreadyAttached", - generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount, - details: Self.attachContext(webView: webView, host: nsView) - ) - #endif - } + let hostOwnsPortal = useLocalInlineHosting + ? updateUsingLocalInlineHosting(nsView, context: context, webView: webView) + : updateUsingWindowPortal(nsView, context: context, webView: webView) + Self.applyWebViewFirstResponderPolicy( + panel: panel, + webView: webView, + isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal + ) Self.applyFocus( panel: panel, webView: webView, nsView: nsView, - shouldFocusWebView: shouldFocusWebView, - isPanelFocused: isPanelFocused + shouldFocusWebView: shouldFocusWebView && isCurrentPaneOwner && hostOwnsPortal, + isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal ) } @@ -3350,56 +5711,95 @@ struct WebViewRepresentable: NSViewRepresentable { isPanelFocused: Bool ) { // Focus handling. Avoid fighting the address bar when it is focused. - guard let window = nsView.window else { return } + guard let window = nsView.window else { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=skip reason=no_window shouldFocus=\(shouldFocusWebView ? 1 : 0) " + + "panelFocused=\(isPanelFocused ? 1 : 0)" + ) +#endif + return + } + if isPanelFocused && responderChainContains(window.firstResponder, target: webView) { + panel.noteWebViewFocused() + } if shouldFocusWebView { if panel.shouldSuppressWebViewFocus() { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=skip reason=suppressed panelFocused=\(isPanelFocused ? 1 : 0)" + ) +#endif return } if responderChainContains(window.firstResponder, target: webView) { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=skip reason=already_first_responder_chain" + ) +#endif return } - window.makeFirstResponder(webView) + let result = window.makeFirstResponder(webView) + if result { + panel.noteWebViewFocused() + } +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=focus result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" + ) +#endif } else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) { // Only force-resign WebView focus when this panel itself is not focused. // If the panel is focused but the omnibar-focus state is briefly stale, aggressively // clearing first responder here can undo programmatic webview focus (socket tests). - window.makeFirstResponder(nil) + let result = window.makeFirstResponder(nil) +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=resign result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" + ) +#endif } } + 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 - coordinator.attachRetryCount = 0 coordinator.attachGeneration += 1 clearPortalCallbacks(for: nsView) + if let panel = coordinator.panel, let host = nsView as? HostContainerView { + panel.releasePortalHostIfOwned( + hostId: ObjectIdentifier(host), + reason: "dismantle" + ) + } guard let webView = coordinator.webView else { return } let panel = coordinator.panel - if coordinator.usesWindowPortal { - coordinator.usesWindowPortal = false - coordinator.lastPortalHostId = nil - - // During split/layout churn we keep the WKWebView portal-hosted so DevTools - // does not lose state. BrowserPanel deinit explicitly detaches on real teardown. - if let panel, panel.shouldPreserveWebViewAttachmentDuringTransientHide() { - #if DEBUG - logDevToolsState( - panel, - event: "dismantle.portal.keepAttached", - generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount, - details: attachContext(webView: webView, host: nsView) - ) - #endif - return - } - - BrowserWindowPortalRegistry.detach(webView: webView) - return - } - // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. let window = webView.window ?? nsView.window @@ -3412,7 +5812,7 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "dismantle.resignFirstResponder", generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount, + retryCount: 0, details: attachContext(webView: webView, host: nsView) + " " + state.flags ) } @@ -3421,36 +5821,27 @@ struct WebViewRepresentable: NSViewRepresentable { } } - // During split/layout churn, SwiftUI may tear down a host view while a new one is still - // coming online. When DevTools is intended open, avoid eagerly detaching here. - if let panel, - panel.shouldPreserveWebViewAttachmentDuringTransientHide(), - webView.superview === nsView { - #if DEBUG - logDevToolsState( - panel, - event: "dismantle.skipDetach.devTools", - generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount, - details: attachContext(webView: webView, host: nsView) - ) - #endif - return - } + // SwiftUI can transiently dismantle/rebuild the browser host view during split + // rearrangement. Do not detach the portal-hosted WKWebView or clear its pane-drop + // context here; explicit teardown still happens on real web view replacement and + // panel teardown, and preserving this state lets internal tab drags re-enter the + // browser pane while SwiftUI churns underneath. + BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 + } - if webView.superview === nsView { - webView.removeFromSuperview() - #if DEBUG - if let panel { - logDevToolsState( - panel, - event: "dismantle.detached", - generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount, - details: attachContext(webView: webView, host: nsView) - ) - } - #endif + private func currentPaneDropContext() -> BrowserPaneDropContext? { + guard let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: panel.workspaceId), + let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }), + let paneId = workspace.paneId(forPanelId: panel.id) else { + return nil } + return BrowserPaneDropContext( + workspaceId: panel.workspaceId, + panelId: panel.id, + paneId: paneId + ) } } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 7ee2d00a..aaf751d9 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1,5 +1,7 @@ import AppKit +import Bonsplit import ObjectiveC +import UniformTypeIdentifiers import WebKit /// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W), @@ -7,6 +9,37 @@ import WebKit /// key equivalents first so app-level shortcuts continue to work when WebKit is /// the first responder. final class CmuxWebView: WKWebView { + // 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? @@ -22,34 +55,146 @@ final class CmuxWebView: WKWebView { 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<T>(_ body: () -> T) -> T { + 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 + } + return body() + } 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 { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var handled = false + defer { + CmuxTypingTiming.logDuration( + path: "browser.web.performKeyEquivalent", + startedAt: typingTimingStart, + event: event, + extra: "handled=\(handled ? 1 : 0)" + ) + } +#endif + 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 { + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result + } + + if !shouldRouteCommandEquivalentDirectlyToMainMenu(event) { + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result + } + // Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc). if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { +#if DEBUG + handled = true +#endif return true } // Handle app-level shortcuts that are not menu-backed (for example split commands). // Without this, WebKit can consume Cmd-based shortcuts before the app monitor sees them. if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { +#if DEBUG + handled = true +#endif return true } - return super.performKeyEquivalent(with: event) + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result } override func keyDown(with event: NSEvent) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var route = "super" + defer { + CmuxTypingTiming.logDuration( + path: "browser.web.keyDown", + startedAt: typingTimingStart, + event: event, + extra: "route=\(route)" + ) + } +#endif // Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent. // Route them through the same app-level shortcut handler as a fallback. if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { +#if DEBUG + route = "appShortcut" +#endif return } @@ -63,20 +208,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: @@ -86,25 +259,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 = """ (() => { @@ -137,11 +308,179 @@ final class CmuxWebView: WKWebView { private var fallbackDownloadLinkedFileTarget: AnyObject? private var fallbackDownloadLinkedFileAction: Selector? + private static func makeContextDownloadTraceID(prefix: String) -> String { +#if DEBUG + return "\(prefix)-\(UUID().uuidString.prefix(8))" +#else + return prefix +#endif + } + + private func debugContextDownload(_ message: @autoclosure () -> String) { +#if DEBUG + dlog(message()) +#endif + } + + private static func selectorName(_ selector: Selector?) -> String { + guard let selector else { return "nil" } + return NSStringFromSelector(selector) + } + + private func debugLogContextMenuDownloadCandidate(_ item: NSMenuItem, index: Int) { + let identifier = item.identifier?.rawValue ?? "nil" + let title = item.title + let actionName = Self.selectorName(item.action) + let idToken = Self.normalizedContextMenuToken(identifier) + let titleToken = Self.normalizedContextMenuToken(title) + let actionToken = Self.normalizedContextMenuToken(actionName) + guard idToken.contains("download") + || titleToken.contains("download") + || actionToken.contains("download") else { + return + } + debugContextDownload( + "browser.ctxdl.menu item index=\(index) id=\(identifier) title=\(title) action=\(actionName)" + ) + } + + private struct ParsedDataURL { + let data: Data + let mimeType: String? + } + + private static func parseDataURL(_ url: URL) -> ParsedDataURL? { + let absolute = url.absoluteString + guard absolute.hasPrefix("data:"), + let commaIndex = absolute.firstIndex(of: ",") else { + return nil + } + + let headerStart = absolute.index(absolute.startIndex, offsetBy: 5) + let header = String(absolute[headerStart..<commaIndex]) + let payloadStart = absolute.index(after: commaIndex) + let payload = String(absolute[payloadStart...]) + + let segments = header.split(separator: ";", omittingEmptySubsequences: false).map(String.init) + let mimeType = segments.first.flatMap { $0.isEmpty ? nil : $0 } + let isBase64 = segments.dropFirst().contains { $0.caseInsensitiveCompare("base64") == .orderedSame } + + if isBase64 { + guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { + return nil + } + return ParsedDataURL(data: data, mimeType: mimeType) + } + + guard let decoded = payload.removingPercentEncoding else { return nil } + return ParsedDataURL(data: Data(decoded.utf8), mimeType: mimeType) + } + + private static func filenameExtension(forMIMEType mimeType: String?) -> String? { + guard let mimeType, !mimeType.isEmpty else { return nil } + if #available(macOS 11.0, *) { + if let preferred = UTType(mimeType: mimeType)?.preferredFilenameExtension, !preferred.isEmpty { + return preferred + } + } + switch mimeType.lowercased() { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/webp": + return "webp" + case "image/gif": + return "gif" + case "text/html": + return "html" + case "text/plain": + return "txt" + default: + return nil + } + } + + private static func suggestedFilenameForDataURL( + mimeType: String?, + suggestedFilename: String? + ) -> String { + if let suggested = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines), + !suggested.isEmpty { + return suggested + } + let ext = filenameExtension(forMIMEType: mimeType) ?? "bin" + let base = (mimeType?.lowercased().hasPrefix("image/") ?? false) ? "image" : "download" + return "\(base).\(ext)" + } + + private static func normalizedContextMenuToken(_ value: String?) -> String { + guard let value else { return "" } + let lowered = value.lowercased() + let alphanumerics = CharacterSet.alphanumerics + let scalars = lowered.unicodeScalars.filter { alphanumerics.contains($0) } + return String(String.UnicodeScalarView(scalars)) + } + + private func isDownloadImageMenuItem(_ item: NSMenuItem) -> Bool { + let identifier = Self.normalizedContextMenuToken(item.identifier?.rawValue) + if identifier.contains("downloadimage") { + return true + } + + let title = Self.normalizedContextMenuToken(item.title) + if title.contains("downloadimage") { + return true + } + + if let action = item.action { + let actionName = Self.normalizedContextMenuToken(NSStringFromSelector(action)) + if actionName.contains("downloadimage") { + return true + } + } + + return false + } + + private func isDownloadLinkedFileMenuItem(_ item: NSMenuItem) -> Bool { + let identifier = Self.normalizedContextMenuToken(item.identifier?.rawValue) + if identifier.contains("downloadlinkedfile") + || identifier.contains("downloadlinktodisk") { + return true + } + + let title = Self.normalizedContextMenuToken(item.title) + if title.contains("downloadlinkedfile") + || title.contains("downloadlinktodisk") { + return true + } + + if let action = item.action { + let actionName = Self.normalizedContextMenuToken(NSStringFromSelector(action)) + if actionName.contains("downloadlinkedfile") + || actionName.contains("downloadlinktodisk") { + return true + } + } + + return false + } + private func isDownloadableScheme(_ url: URL) -> Bool { let scheme = url.scheme?.lowercased() ?? "" return scheme == "http" || scheme == "https" || scheme == "file" } + private func isDataURLScheme(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() ?? "" + return scheme == "data" + } + + private func isDownloadSupportedScheme(_ url: URL) -> Bool { + return isDownloadableScheme(url) || isDataURLScheme(url) + } + private func isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool { guard target === self else { return false } return action == #selector(contextMenuDownloadImage(_:)) @@ -150,7 +489,7 @@ final class CmuxWebView: WKWebView { 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), + guard let 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"] @@ -178,6 +517,43 @@ final class CmuxWebView: WKWebView { resolveGoogleRedirectURL(url) ?? url } + private func isLikelyFaviconURL(_ url: URL) -> Bool { + let lower = url.absoluteString.lowercased() + if lower.contains("favicon") { return true } + let name = url.lastPathComponent.lowercased() + return name.hasPrefix("favicon") + } + + private func isLikelyImageURL(_ url: URL) -> Bool { + if isDataURLScheme(url) { + guard let parsed = Self.parseDataURL(url), + let mime = parsed.mimeType?.lowercased() else { + return false + } + return mime.hasPrefix("image/") + } + guard isDownloadableScheme(url) else { return false } + let ext = url.pathExtension.lowercased() + if [ + "jpg", "jpeg", "png", "webp", "gif", "bmp", + "svg", "avif", "heic", "heif", "tif", "tiff", "ico" + ].contains(ext) { + return true + } + let lower = url.absoluteString.lowercased() + if lower.contains("imgurl=") + || lower.contains("mediaurl=") + || lower.contains("encrypted-tbn") + || lower.contains("format=jpg") + || lower.contains("format=jpeg") + || lower.contains("format=png") + || lower.contains("format=webp") + || lower.contains("format=gif") { + return true + } + return false + } + private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) { let target = item.target as AnyObject? let action = item.action @@ -210,49 +586,121 @@ final class CmuxWebView: WKWebView { 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); + const x = \(point.x); + const y = \(flippedY); + const normalize = (raw) => { + if (!raw || typeof raw !== 'string') return ''; + const trimmed = raw.trim(); + if (!trimmed) return ''; + if (trimmed.startsWith('//')) return window.location.protocol + trimmed; + return trimmed; + }; + const firstSrcsetURL = (srcset) => { + if (!srcset || typeof srcset !== 'string') return ''; + const first = srcset.split(',').map((part) => part.trim()).find(Boolean); + if (!first) return ''; + const urlPart = first.split(/\\s+/)[0]; + return normalize(urlPart); + }; + const firstBackgroundURL = (value) => { + if (!value || value === 'none') return ''; + const match = /url\\((['"]?)(.*?)\\1\\)/.exec(value); + if (!match || !match[2]) return ''; + return normalize(match[2]); + }; + const collectChain = (start) => { + const out = []; + const seen = new Set(); + const pushParents = (node) => { + while (node && !seen.has(node)) { + seen.add(node); + out.push(node); + node = node.parentElement; } - return chain; }; - for (const el of walk(start)) { - if (!seen.has(el)) { - seen.add(el); - elChain.push(el); + pushParents(start); + if (start && start.tagName === 'PICTURE' && start.querySelector) { + const img = start.querySelector('img'); + if (img) pushParents(img); + } + return out; + }; + const candidateFromElement = (el) => { + if (!el) return ''; + const attr = (name) => normalize(el.getAttribute ? el.getAttribute(name) : ''); + if (el.tagName === 'IMG') { + const imageCandidates = [ + normalize(el.currentSrc || ''), + attr('src'), + firstSrcsetURL(attr('srcset')), + attr('data-src'), + attr('data-iurl'), + attr('data-lazy-src'), + attr('data-original'), + ]; + const foundImage = imageCandidates.find(Boolean); + if (foundImage) return foundImage; + } + const genericAttrs = [ + 'src', 'data-src', 'data-iurl', 'data-lazy-src', + 'data-original', 'data-image', 'data-image-url', + 'data-thumb', 'data-thumbnail-url', 'content' + ]; + for (const name of genericAttrs) { + const v = attr(name); + if (v) return v; + } + const inlineBg = firstBackgroundURL(el.style && el.style.backgroundImage ? el.style.backgroundImage : ''); + if (inlineBg) return inlineBg; + try { + const computed = window.getComputedStyle(el); + const computedBg = firstBackgroundURL(computed ? computed.backgroundImage : ''); + if (computedBg) return computedBg; + } catch (_) {} + if (el.querySelector) { + const nestedImg = el.querySelector('img[src],img[srcset],img[data-src],img[data-iurl],source[srcset]'); + if (nestedImg) { + const nestedCandidates = [ + normalize(nestedImg.currentSrc || ''), + normalize(nestedImg.getAttribute ? nestedImg.getAttribute('src') : ''), + firstSrcsetURL(nestedImg.getAttribute ? nestedImg.getAttribute('srcset') : ''), + normalize(nestedImg.getAttribute ? (nestedImg.getAttribute('data-src') || nestedImg.getAttribute('data-iurl') || '') : '') + ]; + const foundNested = nestedCandidates.find(Boolean); + if (foundNested) return foundNested; + } + const nestedBg = el.querySelector('[style*="background-image"]'); + if (nestedBg) { + const styleValue = nestedBg.getAttribute ? nestedBg.getAttribute('style') : ''; + const bgURL = firstBackgroundURL(styleValue || ''); + if (bgURL) return bgURL; } } - - for (const el of elChain) { - if (el.tagName === 'IMG') { - if (el.currentSrc) return el.currentSrc; - if (el.src) return el.src; + return ''; + }; + const tryNodes = (nodes) => { + for (const start of nodes) { + for (const el of collectChain(start)) { + const found = candidateFromElement(el); + if (found) return found; } - if (el.tagName === 'PICTURE') { - const img = el.querySelector('img'); - if (img) { - if (img.currentSrc) return img.currentSrc; - if (img.src) return img.src; + if (start && start.shadowRoot && start.shadowRoot.elementFromPoint) { + const inner = start.shadowRoot.elementFromPoint(x, y); + if (inner) { + for (const el of collectChain(inner)) { + const found = candidateFromElement(el); + if (found) return found; + } } } } - } - return ''; + return ''; + }; + const all = document.elementsFromPoint ? document.elementsFromPoint(x, y) : []; + const foundFromAll = tryNodes(all); + if (foundFromAll) return foundFromAll; + const single = document.elementFromPoint ? document.elementFromPoint(x, y) : null; + return candidateFromElement(single) || ''; })(); """ evaluateJavaScript(js) { result, _ in @@ -270,28 +718,69 @@ final class CmuxWebView: WKWebView { 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; + const x = \(point.x); + const y = \(flippedY); + const normalize = (raw) => { + if (!raw || typeof raw !== 'string') return ''; + const trimmed = raw.trim(); + if (!trimmed) return ''; + if (trimmed.startsWith('//')) return window.location.protocol + trimmed; + return trimmed; + }; + const collectChain = (start) => { + const out = []; + const seen = new Set(); + while (start && !seen.has(start)) { + seen.add(start); + out.push(start); + start = start.parentElement; } - } - return ''; + return out; + }; + const linkFromElement = (el) => { + if (!el) return ''; + const attr = (name) => normalize(el.getAttribute ? el.getAttribute(name) : ''); + if (el.closest) { + const closestLink = el.closest('a[href],area[href]'); + if (closestLink && closestLink.href) return normalize(closestLink.href); + } + if ((el.tagName === 'A' || el.tagName === 'AREA') && el.href) { + return normalize(el.href); + } + const attrCandidates = ['href', 'data-href', 'data-url', 'data-link', 'data-link-url']; + for (const name of attrCandidates) { + const v = attr(name); + if (v) return v; + } + if (el.querySelector) { + const nestedLink = el.querySelector('a[href],area[href]'); + if (nestedLink && nestedLink.href) return normalize(nestedLink.href); + } + return ''; + }; + const tryNodes = (nodes) => { + for (const start of nodes) { + for (const node of collectChain(start)) { + const found = linkFromElement(node); + if (found) return found; + } + if (start && start.shadowRoot && start.shadowRoot.elementFromPoint) { + const inner = start.shadowRoot.elementFromPoint(x, y); + if (inner) { + for (const node of collectChain(inner)) { + const found = linkFromElement(node); + if (found) return found; + } + } + } + } + return ''; + }; + const nodes = document.elementsFromPoint ? document.elementsFromPoint(x, y) : []; + const found = tryNodes(nodes); + if (found) return found; + const single = document.elementFromPoint ? document.elementFromPoint(x, y) : null; + return linkFromElement(single) || ''; })(); """ evaluateJavaScript(js) { result, _ in @@ -304,6 +793,49 @@ final class CmuxWebView: WKWebView { } } + private func debugInspectElementsAtPoint(_ point: NSPoint, traceID: String, kind: String) { +#if DEBUG + let flippedY = bounds.height - point.y + let js = """ + (() => { + const clip = (value, max = 180) => { + if (value == null) return ''; + const s = String(value); + return s.length > max ? s.slice(0, max) + '…' : s; + }; + const x = \(point.x); + const y = \(flippedY); + const nodes = document.elementsFromPoint ? document.elementsFromPoint(x, y) : []; + const entries = []; + const limit = Math.min(nodes.length, 8); + for (let i = 0; i < limit; i++) { + const el = nodes[i]; + if (!el) continue; + entries.push({ + tag: clip((el.tagName || '').toLowerCase()), + id: clip(el.id || ''), + cls: clip(typeof el.className === 'string' ? el.className : ''), + href: clip(el.href || ''), + src: clip(el.src || ''), + currentSrc: clip(el.currentSrc || ''), + dataHref: clip(el.getAttribute ? el.getAttribute('data-href') : ''), + dataSrc: clip(el.getAttribute ? el.getAttribute('data-src') : '') + }); + } + return JSON.stringify({count: nodes.length, entries}); + })(); + """ + evaluateJavaScript(js) { [weak self] result, _ in + guard let self, + let payload = result as? String, + !payload.isEmpty else { return } + self.debugContextDownload( + "browser.ctxdl.inspect trace=\(traceID) kind=\(kind) payload=\(payload)" + ) + } +#endif + } + private func resolveContextMenuLinkURL(at point: NSPoint, completion: @escaping (URL?) -> Void) { if let contextMenuLinkURLProvider { contextMenuLinkURLProvider(self, point, completion) @@ -325,16 +857,33 @@ final class CmuxWebView: WKWebView { _ = NSWorkspace.shared.open(url) } - private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) { - guard let action else { return } + private func runContextMenuFallback( + action: Selector?, + target: AnyObject?, + sender: Any?, + traceID: String? = nil, + reason: String? = nil + ) { + let trace = traceID ?? "unknown" + guard let action else { + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") action=nil target=\(String(describing: target))" + ) + 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)") + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") skipped=recursive action=\(Self.selectorName(action))" + ) return } - _ = NSApp.sendAction(action, to: target, from: sender) + let dispatched = NSApp.sendAction(action, to: target, from: sender) + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") dispatched=\(dispatched ? 1 : 0) action=\(Self.selectorName(action)) target=\(String(describing: target))" + ) } private func notifyContextMenuDownloadState(_ downloading: Bool) { @@ -352,19 +901,98 @@ final class CmuxWebView: WKWebView { suggestedFilename: String?, sender: Any?, fallbackAction: Selector?, - fallbackTarget: AnyObject? + fallbackTarget: AnyObject?, + traceID: String ) { - guard isDownloadableScheme(url) else { - runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + guard isDownloadSupportedScheme(url) else { + debugContextDownload( + "browser.ctxdl.request trace=\(traceID) stage=rejectUnsupportedScheme url=\(url.absoluteString)" + ) + runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "unsupported_scheme" + ) return } let scheme = url.scheme?.lowercased() ?? "" + debugContextDownload( + "browser.ctxdl.request trace=\(traceID) stage=start scheme=\(scheme) url=\(url.absoluteString)" + ) notifyContextMenuDownloadState(true) + debugContextDownload("browser.ctxdl.state trace=\(traceID) downloading=1") + + if scheme == "data" { + DispatchQueue.main.async { + guard let parsed = Self.parseDataURL(url) else { + self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=parseFailure urlLength=\(url.absoluteString.count)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "data_url_parse_error" + ) + return + } + + let saveName = Self.suggestedFilenameForDataURL( + mimeType: parsed.mimeType, + suggestedFilename: suggestedFilename + ) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=parseSuccess mime=\(parsed.mimeType ?? "nil") bytes=\(parsed.data.count)" + ) + + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = saveName + savePanel.canCreateDirectories = true + savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) + savePanel.begin { result in + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } + do { + try parsed.data.write(to: destURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=saveSuccess path=\(destURL.path)" + ) + } catch { + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "data_save_write_error" + ) + } + } + } + return + } if scheme == "file" { DispatchQueue.main.async { do { let data = try Data(contentsOf: url) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=readSuccess bytes=\(data.count) path=\(url.path)" + ) let filename = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines) let saveName = (filename?.isEmpty == false ? filename! : url.lastPathComponent.isEmpty ? "download" : url.lastPathComponent) let savePanel = NSSavePanel() @@ -373,13 +1001,39 @@ final class CmuxWebView: WKWebView { 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) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { return } - try? data.write(to: destURL, options: .atomic) + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } + do { + try data.write(to: destURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=saveSuccess path=\(destURL.path)" + ) + } catch { + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + } } } catch { self.notifyContextMenuDownloadState(false) - self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=readFailure error=\(error.localizedDescription)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "file_read_error" + ) } } return @@ -399,14 +1053,35 @@ final class CmuxWebView: WKWebView { if let ua = self.customUserAgent, !ua.isEmpty { request.setValue(ua, forHTTPHeaderField: "User-Agent") } + self.debugContextDownload( + "browser.ctxdl.request trace=\(traceID) stage=dispatch method=\(request.httpMethod ?? "GET") cookies=\(cookies.count) referer=\(request.value(forHTTPHeaderField: "Referer") ?? "nil") uaSet=\(request.value(forHTTPHeaderField: "User-Agent") == nil ? 0 : 1)" + ) URLSession.shared.dataTask(with: request) { data, response, error in DispatchQueue.main.async { guard let data, error == nil else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + let mime = response?.mimeType ?? "nil" + let hasResponse = response == nil ? 0 : 1 + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=failure hasResponse=\(hasResponse) status=\(statusCode) mime=\(mime) error=\(error?.localizedDescription ?? "unknown")" + ) self.notifyContextMenuDownloadState(false) - self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "network_error" + ) return } + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + let mime = response?.mimeType ?? "nil" + let expectedLength = response?.expectedContentLength ?? -1 + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=success hasResponse=1 status=\(statusCode) mime=\(mime) bytes=\(data.count) expected=\(expectedLength)" + ) let filenameCandidate = suggestedFilename ?? response?.suggestedFilename ?? url.lastPathComponent @@ -418,12 +1093,32 @@ final class CmuxWebView: WKWebView { 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) + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { return } + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } do { try data.write(to: destURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=saveSuccess path=\(destURL.path)" + ) } catch { - self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "save_write_error" + ) } } } @@ -435,15 +1130,17 @@ final class CmuxWebView: WKWebView { _ url: URL, sender: Any?, fallbackAction: Selector?, - fallbackTarget: AnyObject? + fallbackTarget: AnyObject?, + traceID: String ) { - NSLog("CmuxWebView context download start: %@", url.absoluteString) + debugContextDownload("browser.ctxdl.start trace=\(traceID) url=\(url.absoluteString)") downloadURLViaSession( url, suggestedFilename: nil, sender: sender, fallbackAction: fallbackAction, - fallbackTarget: fallbackTarget + fallbackTarget: fallbackTarget, + traceID: traceID ) } @@ -465,6 +1162,11 @@ final class CmuxWebView: WKWebView { NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"), ] + static func shouldRejectInternalPaneDrag(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + || DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes) + } + override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) { let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) } if !filtered.isEmpty { @@ -472,16 +1174,35 @@ final class CmuxWebView: WKWebView { } } + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] } + return super.draggingEntered(sender) + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] } + return super.draggingUpdated(sender) + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return false } + return super.performDragOperation(sender) + } + override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) lastContextMenuPoint = convert(event.locationInWindow, from: nil) + debugContextDownload( + "browser.ctxdl.menu open itemCount=\(menu.items.count) point=(\(Int(lastContextMenuPoint.x)),\(Int(lastContextMenuPoint.y)))" + ) var openLinkInsertionIndex: Int? var hasDefaultBrowserOpenLinkItem = false for (index, item) in menu.items.enumerated() { + debugLogContextMenuDownloadCandidate(item, index: index) if !hasDefaultBrowserOpenLinkItem, (item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:)) - || item.title == "Open Link in Default Browser") { + || item.title == String(localized: "browser.contextMenu.openLinkInDefaultBrowser", defaultValue: "Open Link in Default Browser")) { hasDefaultBrowserOpenLinkItem = true } @@ -496,12 +1217,13 @@ final class CmuxWebView: WKWebView { // by opening the link as a new surface in the same pane. if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow" || item.title.contains("Open Link in New Window") { - item.title = "Open Link in New Tab" + item.title = String(localized: "browser.contextMenu.openLinkInNewTab", defaultValue: "Open Link in New Tab") } - if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" - || item.title == "Download Image" { - NSLog("CmuxWebView context menu hook: download image") + if isDownloadImageMenuItem(item) { + debugContextDownload( + "browser.ctxdl.menu hook kind=image index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" + ) captureFallbackForMenuItemIfNeeded(item) // Keep global fallback as a secondary safety net. if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { @@ -515,9 +1237,10 @@ final class CmuxWebView: WKWebView { item.action = #selector(contextMenuDownloadImage(_:)) } - if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile" - || item.title == "Download Linked File" { - NSLog("CmuxWebView context menu hook: download linked file") + if isDownloadLinkedFileMenuItem(item) { + debugContextDownload( + "browser.ctxdl.menu hook kind=linked index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" + ) captureFallbackForMenuItemIfNeeded(item) // Keep global fallback as a secondary safety net. if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { @@ -534,7 +1257,7 @@ final class CmuxWebView: WKWebView { if let openLinkInsertionIndex, !hasDefaultBrowserOpenLinkItem { let item = NSMenuItem( - title: "Open Link in Default Browser", + title: String(localized: "browser.contextMenu.openLinkInDefaultBrowser", defaultValue: "Open Link in Default Browser"), action: #selector(contextMenuOpenLinkInDefaultBrowser(_:)), keyEquivalent: "" ) @@ -553,80 +1276,174 @@ final class CmuxWebView: WKWebView { } @objc private func contextMenuDownloadImage(_ sender: Any?) { + let traceID = Self.makeContextDownloadTraceID(prefix: "img") let point = lastContextMenuPoint + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) kind=image point=(\(Int(point.x)),\(Int(point.y)))" + ) let fallback = fallbackFromSender( sender, defaultAction: fallbackDownloadImageAction, defaultTarget: fallbackDownloadImageTarget ) + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) fallback action=\(Self.selectorName(fallback.action)) target=\(String(describing: fallback.target))" + ) findImageURLAtPoint(point) { [weak self] url in guard let self else { return } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image imageURL=\(url?.absoluteString ?? "nil")" + ) + var dataImageURL: URL? + var weakImageURL: URL? 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 + if scheme == "data" { + dataImageURL = url + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image dataURLDetected length=\(url.absoluteString.count)" + ) + } else if scheme == "http" || scheme == "https" || scheme == "file" { + let normalized = self.normalizedLinkedDownloadURL(url) + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image normalizedImageURL=\(normalized.absoluteString)" + ) + if self.isLikelyImageURL(normalized) { + if !self.isLikelyFaviconURL(normalized) { + self.startContextMenuDownload( + normalized, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + weakImageURL = normalized + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image weakCandidateURL=\(normalized.absoluteString) reason=favicon_or_low_confidence" + ) + } else if self.isDownloadableScheme(normalized), !self.isLikelyFaviconURL(normalized) { + // Some image CDNs use extensionless URLs; keep as last-resort candidate. + weakImageURL = normalized + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image weakCandidateURL=\(normalized.absoluteString) reason=unclassified_direct_image_src" + ) + } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image rejectedPrimaryImageURL=\(normalized.absoluteString)" ) - 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 + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image fallbackLinkURL=\(linkURL?.absoluteString ?? "nil")" + ) + if let linkURL { + let normalizedLink = self.normalizedLinkedDownloadURL(linkURL) + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image normalizedFallbackLinkURL=\(normalizedLink.absoluteString)" ) - return + if self.isDownloadableScheme(normalizedLink), + self.isLikelyImageURL(normalizedLink), + !self.isLikelyFaviconURL(normalizedLink) { + self.startContextMenuDownload( + normalizedLink, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + 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 + + if let dataImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image fallbackToDataURL=1" + ) + self.startContextMenuDownload( + dataImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID ) return } - NSLog("CmuxWebView context download image fallback to link URL: %@", linkURL.absoluteString) - self.startContextMenuDownload( - linkURL, + if let weakImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image fallbackToWeakCandidate=1" + ) + self.startContextMenuDownload( + weakImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + + if let linkURL { + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender, + traceID: traceID, + reason: "fallback_link_not_image" + ) + return + } + + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target + traceID: traceID, + reason: "no_image_or_link_url" ) } } } @objc private func contextMenuDownloadLinkedFile(_ sender: Any?) { + let traceID = Self.makeContextDownloadTraceID(prefix: "lnk") let point = lastContextMenuPoint + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) kind=linked point=(\(Int(point.x)),\(Int(point.y)))" + ) let fallback = fallbackFromSender( sender, defaultAction: fallbackDownloadLinkedFileAction, defaultTarget: fallbackDownloadLinkedFileTarget ) + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) fallback action=\(Self.selectorName(fallback.action)) target=\(String(describing: fallback.target))" + ) findLinkURLAtPoint(point) { [weak self] url in guard let self else { return } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked linkURL=\(url?.absoluteString ?? "nil")" + ) 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.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedLinkURL=\(normalized.absoluteString)" + ) + if self.isDownloadSupportedScheme(normalized) { self.startContextMenuDownload( normalized, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + fallbackTarget: fallback.target, + traceID: traceID ) return } @@ -634,44 +1451,90 @@ final class CmuxWebView: WKWebView { // Fallback 1: image URL under cursor (useful on image-heavy result pages). self.findImageURLAtPoint(point) { imageURL in + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackImageURL=\(imageURL?.absoluteString ?? "nil")" + ) + var dataImageURL: URL? 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 + fallbackTarget: fallback.target, + traceID: traceID ) return } + if let imageURL, self.isDataURLScheme(imageURL) { + dataImageURL = imageURL + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackDataURLDetected length=\(imageURL.absoluteString.count)" + ) + } // Fallback 2: simpler nearest-anchor lookup. self.findLinkAtPoint(point) { fallbackURL in + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked nearestAnchorURL=\(fallbackURL?.absoluteString ?? "nil")" + ) guard let fallbackURL else { - NSLog("CmuxWebView context download linked file: URL nil, using fallback action") + if let dataImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1" + ) + self.startContextMenuDownload( + dataImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, target: fallback.target, - sender: sender + sender: sender, + traceID: traceID, + reason: "no_link_or_image_url" ) 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.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedNearestAnchorURL=\(normalized.absoluteString)" + ) + guard self.isDownloadSupportedScheme(normalized) else { + if let dataImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1" + ) + self.startContextMenuDownload( + dataImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, target: fallback.target, - sender: sender + sender: sender, + traceID: traceID, + reason: "nearest_anchor_unsupported_scheme" ) 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 + fallbackTarget: fallback.target, + traceID: traceID ) } } diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift new file mode 100644 index 00000000..74e48b89 --- /dev/null +++ b/Sources/Panels/MarkdownPanel.swift @@ -0,0 +1,182 @@ +import Foundation +import Combine + +/// A panel that renders a markdown file with live file-watching. +/// When the file changes on disk, the content is automatically reloaded. +@MainActor +final class MarkdownPanel: Panel, ObservableObject { + let id: UUID + let panelType: PanelType = .markdown + + /// Absolute path to the markdown file being displayed. + let filePath: String + + /// The workspace this panel belongs to. + private(set) var workspaceId: UUID + + /// Current markdown content read from the file. + @Published private(set) var content: String = "" + + /// Title shown in the tab bar (filename). + @Published private(set) var displayTitle: String = "" + + /// SF Symbol icon for the tab bar. + var displayIcon: String? { "doc.richtext" } + + /// Whether the file has been deleted or is unreadable. + @Published private(set) var isFileUnavailable: Bool = false + + /// Token incremented to trigger focus flash animation. + @Published private(set) var focusFlashToken: Int = 0 + + // MARK: - File watching + + // nonisolated(unsafe) because deinit is not guaranteed to run on the + // main actor, but DispatchSource.cancel() is thread-safe. + private nonisolated(unsafe) var fileWatchSource: DispatchSourceFileSystemObject? + private var fileDescriptor: Int32 = -1 + private var isClosed: Bool = false + private let watchQueue = DispatchQueue(label: "com.cmux.markdown-file-watch", qos: .utility) + + /// Maximum number of reattach attempts after a file delete/rename event. + private static let maxReattachAttempts = 6 + /// Delay between reattach attempts (total window: attempts * delay = 3s). + private static let reattachDelay: TimeInterval = 0.5 + + // MARK: - Init + + init(workspaceId: UUID, filePath: String) { + self.id = UUID() + self.workspaceId = workspaceId + self.filePath = filePath + self.displayTitle = (filePath as NSString).lastPathComponent + + loadFileContent() + startFileWatcher() + if isFileUnavailable && fileWatchSource == nil { + // Session restore can create a panel before the file is recreated. + // Retry briefly so atomic-rename recreations can reconnect. + scheduleReattach(attempt: 1) + } + } + + // MARK: - Panel protocol + + func focus() { + // Markdown panel is read-only; no first responder to manage. + } + + func unfocus() { + // No-op for read-only panel. + } + + func close() { + isClosed = true + stopFileWatcher() + } + + func triggerFlash() { + focusFlashToken += 1 + } + + // MARK: - File I/O + + private func loadFileContent() { + do { + let newContent = try String(contentsOfFile: filePath, encoding: .utf8) + content = newContent + isFileUnavailable = false + } catch { + // Fallback: try ISO Latin-1, which accepts all 256 byte values, + // covering legacy encodings like Windows-1252. + if let data = FileManager.default.contents(atPath: filePath), + let decoded = String(data: data, encoding: .isoLatin1) { + content = decoded + isFileUnavailable = false + } else { + isFileUnavailable = true + } + } + } + + // MARK: - File watcher via DispatchSource + + private func startFileWatcher() { + let fd = open(filePath, O_EVTONLY) + guard fd >= 0 else { return } + fileDescriptor = fd + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .delete, .rename, .extend], + queue: watchQueue + ) + + source.setEventHandler { [weak self] in + guard let self else { return } + let flags = source.data + if flags.contains(.delete) || flags.contains(.rename) { + // File was deleted or renamed. The old file descriptor points to + // a stale inode, so we must always stop and reattach the watcher + // even if the new file is already readable (atomic save case). + DispatchQueue.main.async { + self.stopFileWatcher() + self.loadFileContent() + if self.isFileUnavailable { + // File not yet replaced — retry until it reappears. + self.scheduleReattach(attempt: 1) + } else { + // File already replaced — reattach to the new inode immediately. + self.startFileWatcher() + } + } + } else { + // Content changed — reload. + DispatchQueue.main.async { + self.loadFileContent() + } + } + } + + source.setCancelHandler { + Darwin.close(fd) + } + + source.resume() + fileWatchSource = source + } + + /// Retry reattaching the file watcher up to `maxReattachAttempts` times. + /// Each attempt checks if the file has reappeared. Bails out early if + /// the panel has been closed. + private func scheduleReattach(attempt: Int) { + guard attempt <= Self.maxReattachAttempts else { return } + watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in + guard let self else { return } + DispatchQueue.main.async { + guard !self.isClosed else { return } + if FileManager.default.fileExists(atPath: self.filePath) { + self.isFileUnavailable = false + self.loadFileContent() + self.startFileWatcher() + } else { + self.scheduleReattach(attempt: attempt + 1) + } + } + } + } + + private func stopFileWatcher() { + if let source = fileWatchSource { + source.cancel() + fileWatchSource = nil + } + // File descriptor is closed by the cancel handler. + fileDescriptor = -1 + } + + deinit { + // DispatchSource cancel is safe from any thread. + fileWatchSource?.cancel() + } +} diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift new file mode 100644 index 00000000..dc8d7c6c --- /dev/null +++ b/Sources/Panels/MarkdownPanelView.swift @@ -0,0 +1,355 @@ +import AppKit +import SwiftUI +import MarkdownUI + +/// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI. +struct MarkdownPanelView: View { + @ObservedObject var panel: MarkdownPanel + let isFocused: Bool + let isVisibleInUI: Bool + let portalPriority: Int + let onRequestPanelFocus: () -> Void + + @State private var focusFlashOpacity: Double = 0.0 + @State private var focusFlashAnimationGeneration: Int = 0 + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Group { + if panel.isFileUnavailable { + fileUnavailableView + } else { + markdownContentView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(backgroundColor) + .overlay { + RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) + .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) + .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) + .padding(FocusFlashPattern.ringInset) + .allowsHitTesting(false) + } + .overlay { + if isVisibleInUI { + // Observe left-clicks without intercepting them so markdown text + // selection and link activation continue to use the native path. + MarkdownPointerObserver(onPointerDown: onRequestPanelFocus) + } + } + .onChange(of: panel.focusFlashToken) { _ in + triggerFocusFlashAnimation() + } + } + + // MARK: - Content + + private var markdownContentView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // File path breadcrumb + filePathHeader + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + + Divider() + .padding(.horizontal, 16) + + // Rendered markdown + Markdown(panel.content) + .markdownTheme(cmuxMarkdownTheme) + .textSelection(.enabled) + .padding(.horizontal, 24) + .padding(.vertical, 16) + } + } + } + + private var filePathHeader: some View { + HStack(spacing: 6) { + Image(systemName: "doc.richtext") + .foregroundColor(.secondary) + .font(.system(size: 12)) + Text(panel.filePath) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + } + + private var fileUnavailableView: some View { + VStack(spacing: 12) { + Image(systemName: "doc.questionmark") + .font(.system(size: 40)) + .foregroundColor(.secondary) + Text(String(localized: "markdown.fileUnavailable.title", defaultValue: "File unavailable")) + .font(.headline) + .foregroundColor(.primary) + Text(panel.filePath) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 24) + Text(String(localized: "markdown.fileUnavailable.message", defaultValue: "The file may have been moved or deleted.")) + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Theme + + private var backgroundColor: Color { + colorScheme == .dark + ? Color(nsColor: NSColor(white: 0.12, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.98, alpha: 1.0)) + } + + private var cmuxMarkdownTheme: Theme { + let isDark = colorScheme == .dark + + return Theme() + // Text + .text { + ForegroundColor(isDark ? .white.opacity(0.9) : .primary) + FontSize(14) + } + // Headings + .heading1 { configuration in + VStack(alignment: .leading, spacing: 8) { + configuration.label + .markdownTextStyle { + FontWeight(.bold) + FontSize(28) + ForegroundColor(isDark ? .white : .primary) + } + Divider() + } + .markdownMargin(top: 24, bottom: 16) + } + .heading2 { configuration in + VStack(alignment: .leading, spacing: 6) { + configuration.label + .markdownTextStyle { + FontWeight(.bold) + FontSize(22) + ForegroundColor(isDark ? .white : .primary) + } + Divider() + } + .markdownMargin(top: 20, bottom: 12) + } + .heading3 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(18) + ForegroundColor(isDark ? .white : .primary) + } + .markdownMargin(top: 16, bottom: 8) + } + .heading4 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(16) + ForegroundColor(isDark ? .white : .primary) + } + .markdownMargin(top: 12, bottom: 6) + } + .heading5 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.medium) + FontSize(14) + ForegroundColor(isDark ? .white : .primary) + } + .markdownMargin(top: 10, bottom: 4) + } + .heading6 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.medium) + FontSize(13) + ForegroundColor(isDark ? .white.opacity(0.7) : .secondary) + } + .markdownMargin(top: 8, bottom: 4) + } + // Code blocks + .codeBlock { configuration in + ScrollView(.horizontal, showsIndicators: true) { + configuration.label + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(13) + ForegroundColor(isDark ? Color(red: 0.9, green: 0.9, blue: 0.9) : Color(red: 0.2, green: 0.2, blue: 0.2)) + } + .padding(12) + } + .background(isDark + ? Color(nsColor: NSColor(white: 0.08, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.93, alpha: 1.0))) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .markdownMargin(top: 8, bottom: 8) + } + // Inline code + .code { + FontFamilyVariant(.monospaced) + FontSize(13) + ForegroundColor(isDark ? Color(red: 0.85, green: 0.6, blue: 0.95) : Color(red: 0.6, green: 0.2, blue: 0.7)) + BackgroundColor(isDark + ? Color(nsColor: NSColor(white: 0.18, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.92, alpha: 1.0))) + } + // Block quotes + .blockquote { configuration in + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 1.5) + .fill(isDark ? Color.white.opacity(0.2) : Color.gray.opacity(0.4)) + .frame(width: 3) + configuration.label + .markdownTextStyle { + ForegroundColor(isDark ? .white.opacity(0.6) : .secondary) + FontSize(14) + } + .padding(.leading, 12) + } + .markdownMargin(top: 8, bottom: 8) + } + // Links + .link { + ForegroundColor(Color.accentColor) + } + // Strong + .strong { + FontWeight(.semibold) + } + // Tables + .table { configuration in + configuration.label + .markdownTableBorderStyle(.init(color: isDark ? .white.opacity(0.15) : .gray.opacity(0.3))) + .markdownTableBackgroundStyle( + .alternatingRows( + isDark + ? Color(nsColor: NSColor(white: 0.14, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.96, alpha: 1.0)), + isDark + ? Color(nsColor: NSColor(white: 0.10, alpha: 1.0)) + : Color(nsColor: NSColor(white: 1.0, alpha: 1.0)) + ) + ) + .markdownMargin(top: 8, bottom: 8) + } + // Thematic break (horizontal rule) + .thematicBreak { + Divider() + .markdownMargin(top: 16, bottom: 16) + } + // List items + .listItem { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 4) + } + // Paragraphs + .paragraph { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 8) + } + } + + // MARK: - Focus Flash + + private func triggerFocusFlashAnimation() { + focusFlashAnimationGeneration &+= 1 + let generation = focusFlashAnimationGeneration + focusFlashOpacity = FocusFlashPattern.values.first ?? 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 + } + } + } + } + + private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation { + switch curve { + case .easeIn: + return .easeIn(duration: duration) + case .easeOut: + return .easeOut(duration: duration) + } + } +} + +private struct MarkdownPointerObserver: NSViewRepresentable { + let onPointerDown: () -> Void + + func makeNSView(context: Context) -> MarkdownPanelPointerObserverView { + let view = MarkdownPanelPointerObserverView() + view.onPointerDown = onPointerDown + return view + } + + func updateNSView(_ nsView: MarkdownPanelPointerObserverView, context: Context) { + nsView.onPointerDown = onPointerDown + } +} + +final class MarkdownPanelPointerObserverView: NSView { + var onPointerDown: (() -> Void)? + private var eventMonitor: Any? + + override var mouseDownCanMoveWindow: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + installEventMonitorIfNeeded() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func shouldHandle(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + let window, + event.window === window, + !isHiddenOrHasHiddenAncestor else { return false } + let point = convert(event.locationInWindow, from: nil) + return bounds.contains(point) + } + + func handleEventIfNeeded(_ event: NSEvent) -> NSEvent { + guard shouldHandle(event) else { return event } + DispatchQueue.main.async { [weak self] in + self?.onPointerDown?() + } + return event + } + + private func installEventMonitorIfNeeded() { + guard eventMonitor == nil else { return } + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + self?.handleEventIfNeeded(event) ?? event + } + } +} diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 427d53c8..bcbf5b7d 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -1,10 +1,64 @@ import Foundation import Combine +import AppKit /// Type of panel content public enum PanelType: String, Codable, Sendable { case terminal case browser + case markdown +} + +public enum TerminalPanelFocusIntent: Equatable { + case surface + case findField +} + +public enum BrowserPanelFocusIntent: Equatable { + case webView + case addressBar + case findField +} + +public enum PanelFocusIntent: Equatable { + case panel + case terminal(TerminalPanelFocusIntent) + case browser(BrowserPanelFocusIntent) +} + +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.) @@ -33,10 +87,66 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI /// Unfocus the panel func unfocus() + + /// Trigger a focus flash animation for this panel. + func triggerFlash() + + /// Capture the panel-local focus target that should be restored later. + func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent + + /// Return the best focus target to restore when this panel becomes active again. + func preferredFocusIntentForActivation() -> PanelFocusIntent + + /// Prime panel-local focus state before activation side effects run. + func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) + + /// Restore a previously captured focus target. + @discardableResult + func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool + + /// Return the semantic focus target currently owned by this panel, if any. + func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? + + /// Explicitly yield a previously owned focus target before another panel restores focus. + @discardableResult + func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool } /// Extension providing default implementations extension Panel { public var displayIcon: String? { nil } public var isDirty: Bool { false } + + func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { + _ = window + return preferredFocusIntentForActivation() + } + + func preferredFocusIntentForActivation() -> PanelFocusIntent { + .panel + } + + func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { + _ = intent + } + + @discardableResult + func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool { + guard intent == .panel else { return false } + focus() + return true + } + + func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { + _ = responder + _ = window + return nil + } + + @discardableResult + func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { + _ = intent + _ = window + return false + } } diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 1374a5a7..fe5d87cf 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -1,9 +1,11 @@ import SwiftUI import Foundation +import Bonsplit /// View that renders the appropriate panel view based on panel type struct PanelContentView: View { let panel: any Panel + let paneId: PaneID let isFocused: Bool let isSelectedInPane: Bool let isVisibleInUI: Bool @@ -35,6 +37,17 @@ struct PanelContentView: View { if let browserPanel = panel as? BrowserPanel { BrowserPanelView( panel: browserPanel, + paneId: paneId, + isFocused: isFocused, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, + onRequestPanelFocus: onRequestPanelFocus + ) + } + case .markdown: + if let markdownPanel = panel as? MarkdownPanel { + MarkdownPanelView( + panel: markdownPanel, isFocused: isFocused, isVisibleInUI: isVisibleInUI, portalPriority: portalPriority, diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index b9a9d767..f9d197a3 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -1,6 +1,7 @@ import Foundation import Combine import AppKit +import Bonsplit /// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol. /// This allows TerminalSurface to be used within the bonsplit-based layout system. @@ -83,13 +84,15 @@ final class TerminalPanel: Panel, ObservableObject { context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: ghostty_surface_config_s? = nil, workingDirectory: String? = nil, + additionalEnvironment: [String: String] = [:], portOrdinal: Int = 0 ) { let surface = TerminalSurface( tabId: workspaceId, context: context, configTemplate: configTemplate, - workingDirectory: workingDirectory + workingDirectory: workingDirectory, + additionalEnvironment: additionalEnvironment ) surface.portOrdinal = portOrdinal self.init(workspaceId: workspaceId, surface: surface) @@ -135,8 +138,30 @@ final class TerminalPanel: Panel, ObservableObject { func close() { // The surface will be cleaned up by its deinit - // Just unfocus before closing + // Detach from the window portal on real close so stale hosted views + // cannot remain above browser panes after split close. + surface.beginPortalCloseLifecycle(reason: "panel.close") +#if DEBUG + let frame = String(format: "%.1fx%.1f", hostedView.frame.width, hostedView.frame.height) + let bounds = String(format: "%.1fx%.1f", hostedView.bounds.width, hostedView.bounds.height) + dlog( + "surface.panel.close.begin panel=\(id.uuidString.prefix(5)) " + + "workspace=\(workspaceId.uuidString.prefix(5)) runtimeSurface=\(surface.surface != nil ? 1 : 0) " + + "inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " + + "hidden=\(hostedView.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)" + ) +#endif unfocus() + hostedView.setVisibleInUI(false) + TerminalWindowPortalRegistry.detach(hostedView: hostedView) +#if DEBUG + dlog( + "surface.panel.close.end panel=\(id.uuidString.prefix(5)) " + + "inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " + + "hidden=\(hostedView.isHidden ? 1 : 0)" + ) +#endif + surface.teardownSurface() } func requestViewReattach() { @@ -165,7 +190,49 @@ final class TerminalPanel: Panel, ObservableObject { hostedView.triggerFlash() } + func triggerNotificationDismissFlash() { + hostedView.triggerFlash(style: .notificationDismiss) + } + func applyWindowBackgroundIfActive() { surface.applyWindowBackgroundIfActive() } + + func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { + .terminal(hostedView.capturePanelFocusIntent(in: window)) + } + + func preferredFocusIntentForActivation() -> PanelFocusIntent { + .terminal(hostedView.preferredPanelFocusIntentForActivation()) + } + + func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { + guard case .terminal(let target) = intent else { return } + hostedView.preparePanelFocusIntentForActivation(target) + } + + @discardableResult + func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool { + switch intent { + case .panel: + focus() + return true + case .terminal(let target): + return hostedView.restorePanelFocusIntent(target) + default: + return false + } + } + + func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { + _ = window + guard let intent = hostedView.ownedPanelFocusIntent(for: responder) else { return nil } + return .terminal(intent) + } + + @discardableResult + func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { + guard case .terminal(let target) = intent else { return false } + return hostedView.yieldPanelFocusIntent(target, in: window) + } } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index ce0ca87d..200104df 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -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) } } diff --git a/Sources/PostHogAnalytics.swift b/Sources/PostHogAnalytics.swift index 091870d8..90eb071f 100644 --- a/Sources/PostHogAnalytics.swift +++ b/Sources/PostHogAnalytics.swift @@ -2,7 +2,6 @@ import AppKit import Foundation import PostHog -@MainActor final class PostHogAnalytics { static let shared = PostHogAnalytics() @@ -12,12 +11,29 @@ final class PostHogAnalytics { // PostHog Cloud US default (matches other cmux properties). private let host = "https://us.i.posthog.com" + private let dailyActiveEvent = "cmux_daily_active" + private let hourlyActiveEvent = "cmux_hourly_active" + private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC" + private let lastActiveHourUTCKey = "posthog.lastActiveHourUTC" + + private let workQueue: DispatchQueue + private let workQueueSpecificKey = DispatchSpecificKey<Void>() + private let utcHourFormatter: DateFormatter + private let utcDayFormatter: DateFormatter private var didStart = false private var activeCheckTimer: Timer? + private init() { + workQueue = DispatchQueue(label: "com.cmux.posthog.analytics", qos: .utility) + utcHourFormatter = Self.makeUTCFormatter("yyyy-MM-dd'T'HH") + utcDayFormatter = Self.makeUTCFormatter("yyyy-MM-dd") + workQueue.setSpecific(key: workQueueSpecificKey, value: ()) + } + private var isEnabled: Bool { + guard TelemetrySettings.enabledForCurrentLaunch else { return false } #if DEBUG // Avoid polluting production analytics while iterating locally. return ProcessInfo.processInfo.environment["CMUX_POSTHOG_ENABLE"] == "1" @@ -27,6 +43,44 @@ final class PostHogAnalytics { } func startIfNeeded() { + dispatchAsyncOnWorkQueue { [weak self] in + self?.startIfNeededOnWorkQueue() + } + } + + func trackActive(reason: String) { + dispatchAsyncOnWorkQueue { [weak self] in + guard let self else { return } + + let didCaptureDaily = self.trackDailyActiveOnWorkQueue(reason: reason, flush: false) + let didCaptureHourly = self.trackHourlyActiveOnWorkQueue(reason: reason, flush: false) + if didCaptureDaily || didCaptureHourly { + // On app focus we can capture both events; flush once to reduce extra work. + PostHogSDK.shared.flush() + } + } + } + + func trackDailyActive(reason: String) { + dispatchAsyncOnWorkQueue { [weak self] in + self?.trackDailyActiveOnWorkQueue(reason: reason, flush: true) + } + } + + func trackHourlyActive(reason: String) { + dispatchAsyncOnWorkQueue { [weak self] in + self?.trackHourlyActiveOnWorkQueue(reason: reason, flush: true) + } + } + + func flush() { + dispatchSyncOnWorkQueue { + guard didStart else { return } + PostHogSDK.shared.flush() + } + } + + private func startIfNeededOnWorkQueue() { guard !didStart else { return } guard isEnabled else { return } @@ -47,30 +101,40 @@ final class PostHogAnalytics { didStart = true + scheduleActiveCheckTimer() + } + + private func scheduleActiveCheckTimer() { // If the app stays in the foreground across midnight, `applicationDidBecomeActive` // won't fire again, so a periodic check avoids undercounting those users. - activeCheckTimer?.invalidate() - activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in + DispatchQueue.main.async { [weak self] in guard let self else { return } - guard NSApp.isActive else { return } - self.trackDailyActive(reason: "activeTimer") + self.activeCheckTimer?.invalidate() + self.activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in + guard let self else { return } + guard NSApp.isActive else { return } + self.trackActive(reason: "activeTimer") + } } } - func trackDailyActive(reason: String) { - startIfNeeded() - guard didStart else { return } + @discardableResult + private func trackDailyActiveOnWorkQueue(reason: String, flush: Bool) -> Bool { + startIfNeededOnWorkQueue() + guard didStart else { return false } let today = utcDayString(Date()) let defaults = UserDefaults.standard if defaults.string(forKey: lastActiveDayUTCKey) == today { - return + return false } defaults.set(today, forKey: lastActiveDayUTCKey) + let event = dailyActiveEvent + PostHogSDK.shared.capture( - "cmux_daily_active", + event, properties: Self.dailyActiveProperties( dayUTC: today, reason: reason, @@ -78,22 +142,77 @@ final class PostHogAnalytics { ) ) - // For DAU we care more about delivery than batching. - PostHogSDK.shared.flush() + if flush && Self.shouldFlushAfterCapture(event: event) { + // For active metrics we care more about delivery than batching. + PostHogSDK.shared.flush() + } + + return true } - func flush() { - guard didStart else { return } - PostHogSDK.shared.flush() + @discardableResult + private func trackHourlyActiveOnWorkQueue(reason: String, flush: Bool) -> Bool { + startIfNeededOnWorkQueue() + guard didStart else { return false } + + let hour = utcHourString(Date()) + let defaults = UserDefaults.standard + if defaults.string(forKey: lastActiveHourUTCKey) == hour { + return false + } + + defaults.set(hour, forKey: lastActiveHourUTCKey) + + let event = hourlyActiveEvent + + PostHogSDK.shared.capture( + event, + properties: Self.hourlyActiveProperties( + hourUTC: hour, + reason: reason, + infoDictionary: Bundle.main.infoDictionary ?? [:] + ) + ) + + if flush && Self.shouldFlushAfterCapture(event: event) { + // Keep hourly freshness and avoid losing a deduped hour on abrupt exits. + PostHogSDK.shared.flush() + } + + return true + } + + private func dispatchAsyncOnWorkQueue(_ block: @escaping () -> Void) { + if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil { + block() + return + } + workQueue.async(execute: block) + } + + private func dispatchSyncOnWorkQueue(_ block: () -> Void) { + if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil { + block() + return + } + workQueue.sync(execute: block) + } + + private func utcHourString(_ date: Date) -> String { + utcHourFormatter.string(from: date) } private func utcDayString(_ date: Date) -> String { + utcDayFormatter.string(from: date) + } + + private static func makeUTCFormatter(_ dateFormat: String) -> DateFormatter { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .iso8601) formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) + formatter.dateFormat = dateFormat + return formatter } nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] { @@ -115,6 +234,28 @@ final class PostHogAnalytics { return properties } + nonisolated static func hourlyActiveProperties( + hourUTC: String, + reason: String, + infoDictionary: [String: Any] + ) -> [String: Any] { + var properties: [String: Any] = [ + "hour_utc": hourUTC, + "reason": reason, + ] + properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new } + return properties + } + + nonisolated static func shouldFlushAfterCapture(event: String) -> Bool { + switch event { + case "cmux_daily_active", "cmux_hourly_active": + return true + default: + return false + } + } + nonisolated private static func versionProperties(infoDictionary: [String: Any]) -> [String: Any] { var properties: [String: Any] = [:] if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty { diff --git a/Sources/SentryHelper.swift b/Sources/SentryHelper.swift new file mode 100644 index 00000000..6623f4c0 --- /dev/null +++ b/Sources/SentryHelper.swift @@ -0,0 +1,45 @@ +import Sentry + +/// Add a Sentry breadcrumb for user-action context in hang/crash reports. +func sentryBreadcrumb(_ message: String, category: String = "ui", data: [String: Any]? = nil) { + guard TelemetrySettings.enabledForCurrentLaunch else { return } + let crumb = Breadcrumb(level: .info, category: category) + crumb.message = message + crumb.data = data + SentrySDK.addBreadcrumb(crumb) +} + +private func sentryCaptureMessage( + _ message: String, + level: SentryLevel, + category: String, + data: [String: Any]?, + contextKey: String? +) { + guard TelemetrySettings.enabledForCurrentLaunch else { return } + _ = SentrySDK.capture(message: message) { scope in + scope.setLevel(level) + scope.setTag(value: category, key: "category") + if let data { + scope.setContext(value: data, key: contextKey ?? category) + } + } +} + +func sentryCaptureWarning( + _ message: String, + category: String = "ui", + data: [String: Any]? = nil, + contextKey: String? = nil +) { + sentryCaptureMessage(message, level: .warning, category: category, data: data, contextKey: contextKey) +} + +func sentryCaptureError( + _ message: String, + category: String = "ui", + data: [String: Any]? = nil, + contextKey: String? = nil +) { + sentryCaptureMessage(message, level: .error, category: category, data: data, contextKey: contextKey) +} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift new file mode 100644 index 00000000..53eb995e --- /dev/null +++ b/Sources/SessionPersistence.swift @@ -0,0 +1,479 @@ +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 SessionMarkdownPanelSnapshot: Codable, Sendable { + var filePath: 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? + var markdown: SessionMarkdownPanelSnapshot? +} + +enum SessionSplitOrientation: String, Codable, Sendable { + case horizontal + case vertical + + init(_ orientation: SplitOrientation) { + switch orientation { + case .horizontal: + self = .horizontal + case .vertical: + self = .vertical + } + } + + var splitOrientation: SplitOrientation { + switch self { + case .horizontal: + return .horizontal + case .vertical: + return .vertical + } + } +} + +struct SessionPaneLayoutSnapshot: Codable, Sendable { + var panelIds: [UUID] + var selectedPanelId: UUID? +} + +struct SessionSplitLayoutSnapshot: Codable, Sendable { + var orientation: SessionSplitOrientation + var dividerPosition: Double + var first: SessionWorkspaceLayoutSnapshot + var second: SessionWorkspaceLayoutSnapshot +} + +indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { + case pane(SessionPaneLayoutSnapshot) + case split(SessionSplitLayoutSnapshot) + + private enum CodingKeys: String, CodingKey { + case type + case pane + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "pane": + self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane)) + case "split": + self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split)) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .pane(let pane): + try container.encode("pane", forKey: .type) + try container.encode(pane, forKey: .pane) + case .split(let split): + try container.encode("split", forKey: .type) + try container.encode(split, forKey: .split) + } + } +} + +struct SessionWorkspaceSnapshot: Codable, Sendable { + var processTitle: String + var customTitle: String? + var customColor: String? + var isPinned: Bool + var currentDirectory: String + var focusedPanelId: UUID? + var layout: SessionWorkspaceLayoutSnapshot + var panels: [SessionPanelSnapshot] + var statusEntries: [SessionStatusEntrySnapshot] + var logEntries: [SessionLogEntrySnapshot] + var progress: SessionProgressSnapshot? + var gitBranch: SessionGitBranchSnapshot? +} + +struct SessionTabManagerSnapshot: Codable, Sendable { + var selectedWorkspaceIndex: Int? + var workspaces: [SessionWorkspaceSnapshot] +} + +struct SessionWindowSnapshot: Codable, Sendable { + var frame: SessionRectSnapshot? + var display: SessionDisplaySnapshot? + var tabManager: SessionTabManagerSnapshot + var sidebar: SessionSidebarSnapshot +} + +struct AppSessionSnapshot: Codable, Sendable { + var version: Int + var createdAt: TimeInterval + var windows: [SessionWindowSnapshot] +} + +enum SessionPersistenceStore { + static func load(fileURL: URL? = nil) -> AppSessionSnapshot? { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil } + guard let data = try? Data(contentsOf: fileURL) else { return nil } + let decoder = JSONDecoder() + guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil } + guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil } + guard !snapshot.windows.isEmpty else { return nil } + return snapshot + } + + @discardableResult + static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false } + let directory = fileURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(snapshot) + try data.write(to: fileURL, options: .atomic) + return true + } catch { + return false + } + } + + static func removeSnapshot(fileURL: URL? = nil) { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return } + try? FileManager.default.removeItem(at: fileURL) + } + + static func defaultSnapshotFileURL( + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + appSupportDirectory: URL? = nil + ) -> URL? { + let resolvedAppSupport: URL + if let appSupportDirectory { + resolvedAppSupport = appSupportDirectory + } else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + resolvedAppSupport = discovered + } else { + return nil + } + let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? bundleIdentifier! + : "com.cmuxterm.app" + let safeBundleId = bundleId.replacingOccurrences( + of: "[^A-Za-z0-9._-]", + with: "_", + options: .regularExpression + ) + return resolvedAppSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("session-\(safeBundleId).json", isDirectory: false) + } +} + +enum SessionScrollbackReplayStore { + static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE" + private static let directoryName = "cmux-session-scrollback" + private static let ansiEscape = "\u{001B}" + private static let ansiReset = "\u{001B}[0m" + + static func replayEnvironment( + for scrollback: String?, + tempDirectory: URL = FileManager.default.temporaryDirectory + ) -> [String: String] { + guard let replayText = normalizedScrollback(scrollback) else { return [:] } + guard let replayFileURL = writeReplayFile( + contents: replayText, + tempDirectory: tempDirectory + ) else { + return [:] + } + return [environmentKey: replayFileURL.path] + } + + private static func normalizedScrollback(_ scrollback: String?) -> String? { + guard let scrollback else { return nil } + guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil } + guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil } + return ansiSafeReplayText(truncated) + } + + /// Preserve ANSI color state safely across replay boundaries. + private static func ansiSafeReplayText(_ text: String) -> String { + guard text.contains(ansiEscape) else { return text } + var output = text + if !output.hasPrefix(ansiReset) { + output = ansiReset + output + } + if !output.hasSuffix(ansiReset) { + output += ansiReset + } + return output + } + + private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? { + guard let data = contents.data(using: .utf8) else { return nil } + let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true) + + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + let fileURL = directory + .appendingPathComponent(UUID().uuidString, isDirectory: false) + .appendingPathExtension("txt") + try data.write(to: fileURL, options: .atomic) + return fileURL + } catch { + return nil + } + } +} diff --git a/Sources/SidebarSelectionState.swift b/Sources/SidebarSelectionState.swift index 6fed3117..78ea1ab5 100644 --- a/Sources/SidebarSelectionState.swift +++ b/Sources/SidebarSelectionState.swift @@ -2,6 +2,9 @@ import SwiftUI @MainActor final class SidebarSelectionState: ObservableObject { - @Published var selection: SidebarSelection = .tabs -} + @Published var selection: SidebarSelection + init(selection: SidebarSelection = .tabs) { + self.selection = selection + } +} diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index a2586136..6a12a955 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -1,5 +1,7 @@ import Foundation +#if canImport(Security) import Security +#endif enum SocketControlMode: String, CaseIterable, Identifiable { case off @@ -16,30 +18,30 @@ enum SocketControlMode: String, CaseIterable, Identifiable { var displayName: String { switch self { case .off: - return "Off" + return String(localized: "socketControl.off.name", defaultValue: "Off") case .cmuxOnly: - return "cmux processes only" + return String(localized: "socketControl.cmuxOnly.name", defaultValue: "cmux processes only") case .automation: - return "Automation mode" + return String(localized: "socketControl.automation.name", defaultValue: "Automation mode") case .password: - return "Password mode" + return String(localized: "socketControl.password.name", defaultValue: "Password mode") case .allowAll: - return "Full open access" + return String(localized: "socketControl.allowAll.name", defaultValue: "Full open access") } } var description: String { switch self { case .off: - return "Disable the local control socket." + return String(localized: "socketControl.off.description", defaultValue: "Disable the local control socket.") case .cmuxOnly: - return "Only processes started inside cmux terminals can send commands." + return String(localized: "socketControl.cmuxOnly.description", defaultValue: "Only processes started inside cmux terminals can send commands.") case .automation: - return "Allow external local automation clients from this macOS user (no ancestry check)." + return String(localized: "socketControl.automation.description", defaultValue: "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." + return String(localized: "socketControl.password.description", defaultValue: "Require socket authentication with a password stored in a local file.") case .allowAll: - return "Allow any local process and user to connect with no auth. Unsafe." + return String(localized: "socketControl.allowAll.description", defaultValue: "Allow any local process and user to connect with no auth. Unsafe.") } } @@ -58,103 +60,228 @@ enum SocketControlMode: String, CaseIterable, Identifiable { } 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 let directoryName = "cmux" + static let fileName = "socket-control-password" + private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion" + private static let keychainMigrationVersion = 1 + private static let legacyKeychainService = "com.cmuxterm.app.socket-control" + private static let legacyKeychainAccount = "local-socket-password" + private struct LazyKeychainFallbackCache { + var hasLoaded = false + var password: String? } + private static let lazyKeychainFallbackLock = NSLock() + private static var lazyKeychainFallbackCache = LazyKeychainFallbackCache() static func configuredPassword( - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + fileURL: URL? = nil, + allowLazyKeychainFallback: Bool = false, + loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() } ) -> String? { - if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty { + if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) { return envPassword } - return try? loadPassword() + let filePassword: String? + do { + filePassword = try loadPassword(fileURL: fileURL) + } catch { + filePassword = nil + } + if let filePassword { + return filePassword + } + guard allowLazyKeychainFallback else { + return nil + } + return cachedLazyKeychainFallbackPassword(loadKeychainPassword: loadKeychainPassword) } static func hasConfiguredPassword( - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + fileURL: URL? = nil, + allowLazyKeychainFallback: Bool = false, + loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() } ) -> Bool { - guard let configured = configuredPassword(environment: environment) else { return false } + guard let configured = configuredPassword( + environment: environment, + fileURL: fileURL, + allowLazyKeychainFallback: allowLazyKeychainFallback, + loadKeychainPassword: loadKeychainPassword + ) else { return false } return !configured.isEmpty } static func verify( password candidate: String, - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + fileURL: URL? = nil, + allowLazyKeychainFallback: Bool = false, + loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() } ) -> Bool { - guard let expected = configuredPassword(environment: environment), !expected.isEmpty else { + guard let expected = configuredPassword( + environment: environment, + fileURL: fileURL, + allowLazyKeychainFallback: allowLazyKeychainFallback, + loadKeychainPassword: loadKeychainPassword + ), !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() + static func migrateLegacyKeychainPasswordIfNeeded( + defaults: UserDefaults = .standard, + fileURL: URL? = nil, + loadLegacyPassword: () -> String? = { loadLegacyPasswordFromKeychain() }, + deleteLegacyPassword: () -> Bool = { deleteLegacyPasswordFromKeychain() } + ) { + guard defaults.integer(forKey: keychainMigrationDefaultsKey) < keychainMigrationVersion else { return } - let data = Data(normalized.utf8) - var lookup = baseQuery - lookup[kSecReturnData as String] = true - lookup[kSecMatchLimit as String] = kSecMatchLimitOne + guard let legacyPassword = normalized(loadLegacyPassword()) else { + defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey) + return + } - 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)) + do { + if try loadPassword(fileURL: fileURL) == nil { + try savePassword(legacyPassword, fileURL: fileURL) } - 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)) + guard deleteLegacyPassword() else { + return } - default: - throw NSError(domain: NSOSStatusErrorDomain, code: Int(lookupStatus)) + defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey) + } catch { + // Leave migration unset so it retries on next launch. } } - static func clearPassword() throws { - let status = SecItemDelete(baseQuery as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + static func loadPassword(fileURL: URL? = nil) throws -> String? { + guard let fileURL = fileURL ?? defaultPasswordFileURL() else { + return nil } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return nil + } + let data = try Data(contentsOf: fileURL) + guard let password = String(data: data, encoding: .utf8) else { + return nil + } + return normalized(password) + } + + static func savePassword(_ password: String, fileURL: URL? = nil) throws { + let normalized = password.trimmingCharacters(in: .newlines) + if normalized.isEmpty { + try clearPassword(fileURL: fileURL) + return + } + + guard let fileURL = fileURL ?? defaultPasswordFileURL() else { + throw NSError( + domain: NSCocoaErrorDomain, + code: NSFileNoSuchFileError, + userInfo: [NSLocalizedDescriptionKey: String(localized: "socketControl.error.passwordFilePath", defaultValue: "Unable to resolve socket password file path.")] + ) + } + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let data = Data(normalized.utf8) + try data.write(to: fileURL, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + } + + static func clearPassword(fileURL: URL? = nil) throws { + guard let fileURL = fileURL ?? defaultPasswordFileURL() else { + return + } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return + } + try FileManager.default.removeItem(at: fileURL) + } + + static func resetLazyKeychainFallbackCacheForTests() { + lazyKeychainFallbackLock.lock() + lazyKeychainFallbackCache = LazyKeychainFallbackCache() + lazyKeychainFallbackLock.unlock() + } + + static func defaultPasswordFileURL( + appSupportDirectory: URL? = nil, + fileManager: FileManager = .default + ) -> URL? { + let resolvedAppSupport: URL + if let appSupportDirectory { + resolvedAppSupport = appSupportDirectory + } else if let discovered = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + resolvedAppSupport = discovered + } else { + return nil + } + return resolvedAppSupport + .appendingPathComponent(directoryName, isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + } + + private static func loadLegacyPasswordFromKeychain() -> String? { +#if canImport(Security) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: legacyKeychainService, + kSecAttrAccount: legacyKeychainAccount, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) +#else + return nil +#endif + } + + private static func deleteLegacyPasswordFromKeychain() -> Bool { +#if canImport(Security) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: legacyKeychainService, + kSecAttrAccount: legacyKeychainAccount, + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound +#else + return false +#endif + } + + private static func cachedLazyKeychainFallbackPassword( + loadKeychainPassword: () -> String? + ) -> String? { + lazyKeychainFallbackLock.lock() + defer { lazyKeychainFallbackLock.unlock() } + if lazyKeychainFallbackCache.hasLoaded { + return lazyKeychainFallbackCache.password + } + lazyKeychainFallbackCache.hasLoaded = true + lazyKeychainFallbackCache.password = normalized(loadKeychainPassword()) + return lazyKeychainFallbackCache.password + } + + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .newlines) + return trimmed.isEmpty ? nil : trimmed } } @@ -163,6 +290,8 @@ struct SocketControlSettings { static let legacyEnabledKey = "socketControlEnabled" static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE" static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" + static let launchTagEnvKey = "CMUX_TAG" + static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug" private static func normalizeMode(_ raw: String) -> String { raw @@ -211,6 +340,65 @@ struct SocketControlSettings { #endif } + static func launchTag( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + guard let raw = environment[launchTagEnvKey] else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + static func shouldBlockUntaggedDebugLaunch( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + isDebugBuild: Bool = SocketControlSettings.isDebugBuild + ) -> Bool { + guard isDebugBuild else { return false } + if isRunningUnderXCTest(environment: environment) { + return false + } + // XCUITest launches the app as a separate process without XCTest env vars, + // so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var. + if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) { + return false + } + + guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty else { + return false + } + + if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") { + return false + } + + guard bundleIdentifier == baseDebugBundleIdentifier else { + return false + } + + return launchTag(environment: environment) == nil + } + + static func isRunningUnderXCTest(environment: [String: String]) -> Bool { + let indicators = [ + "XCTestConfigurationFilePath", + "XCTestBundlePath", + "XCTestSessionIdentifier", + "XCInjectBundle", + "XCInjectBundleInto", + ] + if indicators.contains(where: { key in + guard let value = environment[key] else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) { + return true + } + if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { + return true + } + return false + } + static func socketPath( environment: [String: String] = ProcessInfo.processInfo.environment, bundleIdentifier: String? = Bundle.main.bundleIdentifier, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0bcd5ea6..764b15ce 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -19,22 +19,22 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { var displayName: String { switch self { case .top: - return "Top" + return String(localized: "workspace.placement.top", defaultValue: "Top") case .afterCurrent: - return "After current" + return String(localized: "workspace.placement.afterCurrent", defaultValue: "After current") case .end: - return "End" + return String(localized: "workspace.placement.end", defaultValue: "End") } } var description: String { switch self { case .top: - return "Insert new workspaces at the top of the list." + return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.") case .afterCurrent: - return "Insert new workspaces directly after the active workspace." + return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.") case .end: - return "Append new workspaces to the bottom of the list." + return String(localized: "workspace.placement.end.description", defaultValue: "Append new workspaces to the bottom of the list.") } } } @@ -63,6 +63,48 @@ enum SidebarBranchLayoutSettings { } } +enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { + case leftRail + case solidFill + + var id: String { rawValue } + + var displayName: String { + switch self { + case .leftRail: + return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail") + case .solidFill: + return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill") + } + } +} + +enum SidebarActiveTabIndicatorSettings { + static let styleKey = "sidebarActiveTabIndicatorStyle" + static let defaultStyle: SidebarActiveTabIndicatorStyle = .leftRail + + static func resolvedStyle(rawValue: String?) -> SidebarActiveTabIndicatorStyle { + guard let rawValue else { return defaultStyle } + if let style = SidebarActiveTabIndicatorStyle(rawValue: rawValue) { + return style + } + + // Legacy values from earlier iterations map to the closest modern option. + switch rawValue { + case "rail": + return .leftRail + case "border", "wash", "lift", "typography", "washRail", "blueWashColorRail": + return .solidFill + default: + return defaultStyle + } + } + + static func current(defaults: UserDefaults = .standard) -> SidebarActiveTabIndicatorStyle { + resolvedStyle(rawValue: defaults.string(forKey: styleKey)) + } +} + enum WorkspacePlacementSettings { static let placementKey = "newWorkspacePlacement" static let defaultPlacement: NewWorkspacePlacement = .afterCurrent @@ -104,6 +146,213 @@ enum WorkspacePlacementSettings { } } +struct WorkspaceTabColorEntry: Equatable, Identifiable { + let name: String + let hex: String + + var id: String { "\(name)-\(hex)" } +} + +enum WorkspaceTabColorSettings { + static let defaultOverridesKey = "workspaceTabColor.defaultOverrides" + static let customColorsKey = "workspaceTabColor.customColors" + static let maxCustomColors = 24 + + private static let originalPRPalette: [WorkspaceTabColorEntry] = [ + WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"), + WorkspaceTabColorEntry(name: "Crimson", hex: "#922B21"), + WorkspaceTabColorEntry(name: "Orange", hex: "#A04000"), + WorkspaceTabColorEntry(name: "Amber", hex: "#7D6608"), + WorkspaceTabColorEntry(name: "Olive", hex: "#4A5C18"), + WorkspaceTabColorEntry(name: "Green", hex: "#196F3D"), + WorkspaceTabColorEntry(name: "Teal", hex: "#006B6B"), + WorkspaceTabColorEntry(name: "Aqua", hex: "#0E6B8C"), + WorkspaceTabColorEntry(name: "Blue", hex: "#1565C0"), + WorkspaceTabColorEntry(name: "Navy", hex: "#1A5276"), + WorkspaceTabColorEntry(name: "Indigo", hex: "#283593"), + WorkspaceTabColorEntry(name: "Purple", hex: "#6A1B9A"), + WorkspaceTabColorEntry(name: "Magenta", hex: "#AD1457"), + WorkspaceTabColorEntry(name: "Rose", hex: "#880E4F"), + WorkspaceTabColorEntry(name: "Brown", hex: "#7B3F00"), + WorkspaceTabColorEntry(name: "Charcoal", hex: "#3E4B5E"), + ] + + static var defaultPalette: [WorkspaceTabColorEntry] { + originalPRPalette + } + + static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + defaultPaletteWithOverrides(defaults: defaults) + customColorEntries(defaults: defaults) + } + + static func defaultPaletteWithOverrides(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + let palette = defaultPalette + let overrides = defaultOverrideMap(defaults: defaults) + return palette.map { entry in + WorkspaceTabColorEntry(name: entry.name, hex: overrides[entry.name] ?? entry.hex) + } + } + + static func defaultColorHex(named name: String, defaults: UserDefaults = .standard) -> String { + let palette = defaultPalette + guard let entry = palette.first(where: { $0.name == name }) else { + return palette.first?.hex ?? "#1565C0" + } + return defaultOverrideMap(defaults: defaults)[name] ?? entry.hex + } + + static func setDefaultColor(named name: String, hex: String, defaults: UserDefaults = .standard) { + let palette = defaultPalette + guard let entry = palette.first(where: { $0.name == name }), + let normalized = normalizedHex(hex) else { return } + + var overrides = defaultOverrideMap(defaults: defaults) + if normalized == entry.hex { + overrides.removeValue(forKey: name) + } else { + overrides[name] = normalized + } + saveDefaultOverrideMap(overrides, defaults: defaults) + } + + static func customColors(defaults: UserDefaults = .standard) -> [String] { + guard let raw = defaults.array(forKey: customColorsKey) as? [String] else { return [] } + var result: [String] = [] + var seen: Set<String> = [] + for value in raw { + guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } + result.append(normalized) + if result.count >= maxCustomColors { break } + } + return result + } + + static func customColorEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + customColors(defaults: defaults).enumerated().map { index, hex in + WorkspaceTabColorEntry(name: "Custom \(index + 1)", hex: hex) + } + } + + @discardableResult + static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? { + guard let normalized = normalizedHex(hex) else { return nil } + var colors = customColors(defaults: defaults) + colors.removeAll { $0 == normalized } + colors.insert(normalized, at: 0) + setCustomColors(colors, defaults: defaults) + return normalized + } + + static func removeCustomColor(_ hex: String, defaults: UserDefaults = .standard) { + guard let normalized = normalizedHex(hex) else { return } + var colors = customColors(defaults: defaults) + colors.removeAll { $0 == normalized } + setCustomColors(colors, defaults: defaults) + } + + static func setCustomColors(_ hexes: [String], defaults: UserDefaults = .standard) { + var normalizedColors: [String] = [] + var seen: Set<String> = [] + for value in hexes { + guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } + normalizedColors.append(normalized) + if normalizedColors.count >= maxCustomColors { break } + } + + if normalizedColors.isEmpty { + defaults.removeObject(forKey: customColorsKey) + } else { + defaults.set(normalizedColors, forKey: customColorsKey) + } + } + + static func reset(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: defaultOverridesKey) + defaults.removeObject(forKey: customColorsKey) + } + + static func normalizedHex(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let body = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard body.count == 6 else { return nil } + guard UInt64(body, radix: 16) != nil else { return nil } + return "#" + body.uppercased() + } + + static func displayColor( + hex: String, + colorScheme: ColorScheme, + forceBright: Bool = false + ) -> Color? { + guard let color = displayNSColor(hex: hex, colorScheme: colorScheme, forceBright: forceBright) else { + return nil + } + return Color(nsColor: color) + } + + static func displayNSColor( + hex: String, + colorScheme: ColorScheme, + forceBright: Bool = false + ) -> NSColor? { + guard let normalized = normalizedHex(hex), + let baseColor = NSColor(hex: normalized) else { + return nil + } + + if forceBright || colorScheme == .dark { + return brightenedForDarkAppearance(baseColor) + } + return baseColor + } + + private static func defaultOverrideMap(defaults: UserDefaults) -> [String: String] { + guard let raw = defaults.dictionary(forKey: defaultOverridesKey) as? [String: String] else { return [:] } + let validNames = Set(defaultPalette.map(\.name)) + var normalized: [String: String] = [:] + for (name, hex) in raw { + guard validNames.contains(name), + let normalizedHex = normalizedHex(hex) else { continue } + normalized[name] = normalizedHex + } + return normalized + } + + private static func saveDefaultOverrideMap(_ map: [String: String], defaults: UserDefaults) { + if map.isEmpty { + defaults.removeObject(forKey: defaultOverridesKey) + } else { + defaults.set(map, forKey: defaultOverridesKey) + } + } + + private static func brightenedForDarkAppearance(_ color: NSColor) -> NSColor { + let rgbColor = color.usingColorSpace(.sRGB) ?? color + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + rgbColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + let boostedBrightness = min(1, max(brightness, 0.62) + ((1 - brightness) * 0.28)) + // Preserve neutral grays when brightening to avoid introducing hue shifts. + let boostedSaturation: CGFloat + if saturation <= 0.08 { + boostedSaturation = saturation + } else { + boostedSaturation = min(1, saturation + ((1 - saturation) * 0.12)) + } + + return NSColor( + hue: hue, + saturation: boostedSaturation, + brightness: boostedBrightness, + alpha: alpha + ) + } +} + /// Coalesces repeated main-thread signals into one callback after a short delay. /// Useful for notification storms where only the latest update matters. final class NotificationBurstCoalescer { @@ -309,15 +558,30 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( @MainActor class TabManager: ObservableObject { + private struct InitialWorkspaceGitMetadataSnapshot: Equatable { + let branch: String? + let isDirty: Bool + } + + /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). + /// Used to apply title updates to the correct window instead of NSApp.keyWindow. + weak var window: NSWindow? + @Published var tabs: [Workspace] = [] @Published private(set) var isWorkspaceCycleHot: Bool = false + @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = [] + @Published private(set) var debugPinnedWorkspaceLoadIds: Set<UUID> = [] /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). private static var nextPortOrdinal: Int = 0 + private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } + sentryBreadcrumb("workspace.switch", data: [ + "tabCount": tabs.count + ]) let previousTabId = oldValue if let previousTabId, let previousPanelId = focusedPanelId(for: previousTabId) { @@ -367,6 +631,12 @@ class TabManager: ObservableObject { private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:] private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) + private let initialWorkspaceGitProbeQueue = DispatchQueue( + label: "com.cmux.initial-workspace-git-probe", + qos: .utility + ) + private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:] + private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:] // Recent tab history for back/forward navigation (like browser history) private var tabHistory: [UUID] = [] @@ -462,19 +732,34 @@ class TabManager: ObservableObject { } var isFindVisible: Bool { - selectedTerminalPanel?.searchState != nil + if selectedTerminalPanel?.searchState != nil { return true } + if focusedBrowserPanel?.searchState != nil { return true } + return false } var canUseSelectionForFind: Bool { - selectedTerminalPanel?.hasSelection() == true + if focusedBrowserPanel != nil { return false } + return selectedTerminalPanel?.hasSelection() == true } func startSearch() { - guard let panel = selectedTerminalPanel else { return } - if panel.searchState == nil { + if let browser = focusedBrowserPanel { + browser.startFind() + return + } + guard let panel = selectedTerminalPanel else { +#if DEBUG + dlog("find.startSearch SKIPPED no selectedTerminalPanel") +#endif + return + } + let wasNil = panel.searchState == nil + if wasNil { panel.searchState = TerminalSurface.SearchState() } - NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) +#if DEBUG + dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))") +#endif NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) _ = panel.performBindingAction("start_search") } @@ -484,36 +769,85 @@ class TabManager: ObservableObject { if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } - NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) +#if DEBUG + dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))") +#endif NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) _ = panel.performBindingAction("search_selection") } func findNext() { + if let browser = focusedBrowserPanel, browser.searchState != nil { + browser.findNext() + return + } _ = selectedTerminalPanel?.performBindingAction("search:next") } func findPrevious() { + if let browser = focusedBrowserPanel, browser.searchState != nil { + browser.findPrevious() + return + } _ = selectedTerminalPanel?.performBindingAction("search:previous") } + @discardableResult + func toggleFocusedTerminalCopyMode() -> Bool { + guard let panel = selectedTerminalPanel else { return false } + return panel.surface.toggleKeyboardCopyMode() + } + func hideFind() { + if let browser = focusedBrowserPanel, browser.searchState != nil { + browser.hideFind() + return + } +#if DEBUG + dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")") +#endif selectedTerminalPanel?.searchState = nil } @discardableResult - func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace { - let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() + func addWorkspace( + workingDirectory overrideWorkingDirectory: String? = nil, + select: Bool = true, + eagerLoadTerminal: Bool = false, + placementOverride: NewWorkspacePlacement? = nil, + autoWelcomeIfNeeded: Bool = true + ) -> Workspace { + sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1]) + let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) + let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab() + let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 - let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal) + let newWorkspace = Workspace( + title: "Terminal \(tabs.count + 1)", + workingDirectory: workingDirectory, + portOrdinal: ordinal, + configTemplate: inheritedConfig + ) wireClosedBrowserTracking(for: newWorkspace) - let insertIndex = newTabInsertIndex() + let insertIndex = newTabInsertIndex(placementOverride: placementOverride) if insertIndex >= 0 && insertIndex <= tabs.count { tabs.insert(newWorkspace, at: insertIndex) } else { tabs.append(newWorkspace) } + if let explicitWorkingDirectory, + let terminalPanel = newWorkspace.focusedTerminalPanel { + scheduleInitialWorkspaceGitMetadataRefresh( + workspaceId: newWorkspace.id, + panelId: terminalPanel.id, + directory: explicitWorkingDirectory + ) + } + if eagerLoadTerminal { + requestBackgroundWorkspaceLoad(for: newWorkspace.id) + newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded() + } if select { selectedTabId = newWorkspace.id NotificationCenter.default.post( @@ -529,12 +863,259 @@ class TabManager: ObservableObject { "selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "") ]) #endif + if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) { + if let appDelegate = AppDelegate.shared { + appDelegate.sendWelcomeCommandWhenReady(to: newWorkspace, markShownOnSend: true) + } else { + sendWelcomeWhenReady(to: newWorkspace) + } + } return newWorkspace } + private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) { + let maxAttempts = 60 + if let terminalPanel = workspace.focusedTerminalPanel, + terminalPanel.surface.surface != nil { + // Wait a bit more for the shell prompt to be ready + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + terminalPanel.sendText("cmux welcome\n") + } + return + } + guard attempt < maxAttempts else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1) + } + } + + private func scheduleInitialWorkspaceGitMetadataRefresh( + workspaceId: UUID, + panelId: UUID, + directory: String + ) { + let normalizedDirectory = normalizeDirectory(directory) + let generation = UUID() + cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId) + initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation + +#if DEBUG + dlog( + "workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)" + ) +#endif + + let delays = Self.initialWorkspaceGitProbeDelays + var timers: [DispatchSourceTimer] = [] + for (index, delay) in delays.enumerated() { + let isLastAttempt = index == delays.count - 1 + let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue) + timer.schedule(deadline: .now() + delay, repeating: .never) + timer.setEventHandler { [weak self] in + let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory) + Task { @MainActor [weak self] in + self?.applyInitialWorkspaceGitMetadataSnapshot( + snapshot, + generation: generation, + workspaceId: workspaceId, + panelId: panelId, + expectedDirectory: normalizedDirectory, + isLastAttempt: isLastAttempt + ) + } + } + timers.append(timer) + timer.resume() + } + initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers + } + + private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) { + guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else { + return + } + for timer in timers { + timer.setEventHandler {} + timer.cancel() + } + } + + private func clearInitialWorkspaceGitProbe(workspaceId: UUID) { + initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId) + cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId) + } + + private func applyInitialWorkspaceGitMetadataSnapshot( + _ snapshot: InitialWorkspaceGitMetadataSnapshot, + generation: UUID, + workspaceId: UUID, + panelId: UUID, + expectedDirectory: String, + isLastAttempt: Bool + ) { + defer { + if isLastAttempt, + initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation { + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + } + } + + guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return } + guard let workspace = tabs.first(where: { $0.id == workspaceId }) else { + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + return + } + guard workspace.panels[panelId] != nil else { + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + return + } + + let currentDirectory = normalizedWorkingDirectory( + workspace.panelDirectories[panelId] ?? workspace.currentDirectory + ) + if let currentDirectory, currentDirectory != expectedDirectory { + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) +#if DEBUG + dlog( + "workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " + + "expected=\(expectedDirectory) current=\(currentDirectory)" + ) +#endif + return + } + + workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory) + + let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch) + let nextBranch = snapshot.branch + if let nextBranch { + workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty) + } else { + workspace.clearPanelGitBranch(panelId: panelId) + } + + if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) { + workspace.clearPanelPullRequest(panelId: panelId) + } + +#if DEBUG + let branchLabel = snapshot.branch ?? "none" + dlog( + "workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)" + ) +#endif + } + + private nonisolated static func initialWorkspaceGitMetadataSnapshot( + for directory: String + ) -> InitialWorkspaceGitMetadataSnapshot { + let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"])) + guard let branch else { + return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false) + } + + let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) + let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty) + } + + private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? { + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git", "-C", directory] + arguments + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + + // Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer. + let data = stdout.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + return nil + } + + return String(data: data, encoding: .utf8) + } + + private nonisolated static func normalizedBranchName(_ branch: String?) -> String? { + let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + func requestBackgroundWorkspaceLoad(for workspaceId: UUID) { + guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return } + } + + func completeBackgroundWorkspaceLoad(for workspaceId: UUID) { + guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return } + } + + func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) { + guard !workspaceIds.isEmpty else { return } + debugPinnedWorkspaceLoadIds.formUnion(workspaceIds) + } + + func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) { + guard !workspaceIds.isEmpty else { return } + debugPinnedWorkspaceLoadIds.subtract(workspaceIds) + } + + func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) { + let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds) + if pruned != pendingBackgroundWorkspaceLoadIds { + pendingBackgroundWorkspaceLoadIds = pruned + } + let retained = debugPinnedWorkspaceLoadIds.intersection(existingIds) + if retained != debugPinnedWorkspaceLoadIds { + debugPinnedWorkspaceLoadIds = retained + } + } + // Keep addTab as convenience alias @discardableResult - func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) } + func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace { + addWorkspace(select: select, eagerLoadTerminal: eagerLoadTerminal) + } + + func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? { + guard let workspace = selectedWorkspace else { return nil } + if let focusedTerminal = workspace.focusedTerminalPanel { + return focusedTerminal + } + if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() { + return rememberedTerminal + } + if let focusedPaneId = workspace.bonsplitController.focusedPaneId, + let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) { + return paneTerminal + } + return workspace.terminalPanelForConfigInheritance() + } + + private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? { + if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface { + return cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ) + } + if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints + return config + } + return nil + } private func normalizedWorkingDirectory(_ directory: String?) -> String? { guard let directory else { return nil } @@ -543,8 +1124,8 @@ class TabManager: ObservableObject { return trimmed.isEmpty ? nil : normalized } - private func newTabInsertIndex() -> Int { - let placement = WorkspacePlacementSettings.current() + private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int { + let placement = placementOverride ?? WorkspacePlacementSettings.current() let pinnedCount = tabs.filter { $0.isPinned }.count let selectedIndex = selectedTabId.flatMap { tabId in tabs.firstIndex(where: { $0.id == tabId }) @@ -581,6 +1162,16 @@ class TabManager: ObservableObject { tabs.insert(tab, at: insertIndex) } + func moveTabToTopForNotification(_ tabId: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + let pinnedCount = tabs.filter { $0.isPinned }.count + guard index != pinnedCount else { return } + let tab = tabs[index] + guard !tab.isPinned else { return } + tabs.remove(at: index) + tabs.insert(tab, at: pinnedCount) + } + func moveTabsToTop(_ tabIds: Set<UUID>) { guard !tabIds.isEmpty else { return } let selectedTabs = tabs.filter { tabIds.contains($0.id) } @@ -632,6 +1223,11 @@ class TabManager: ObservableObject { setCustomTitle(tabId: tabId, title: nil) } + func setTabColor(tabId: UUID, color: String?) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.setCustomColor(color) + } + func togglePin(tabId: UUID) { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } let tab = tabs[index] @@ -673,20 +1269,22 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace) { guard tabs.count > 1 else { return } + guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } + sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) + clearInitialWorkspaceGitProbe(workspaceId: workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) unwireClosedBrowserTracking(for: workspace) + workspace.teardownAllPanels() - if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { - tabs.remove(at: index) + tabs.remove(at: index) - if selectedTabId == workspace.id { - // Keep the "focused index" stable when possible: - // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). - // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). - let newIndex = min(index, max(0, tabs.count - 1)) - selectedTabId = tabs[newIndex].id - } + if selectedTabId == workspace.id { + // Keep the "focused index" stable when possible: + // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). + // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). + let newIndex = min(index, max(0, tabs.count - 1)) + selectedTabId = tabs[newIndex].id } } @@ -695,6 +1293,7 @@ class TabManager: ObservableObject { @discardableResult func detachWorkspace(tabId: UUID) -> Workspace? { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } + clearInitialWorkspaceGitProbe(workspaceId: tabId) let removed = tabs.remove(at: index) unwireClosedBrowserTracking(for: removed) @@ -747,6 +1346,31 @@ class TabManager: ObservableObject { closePanelWithConfirmation(tab: tab, panelId: focusedPanelId) } + func canCloseOtherTabsInFocusedPane() -> Bool { + closeOtherTabsInFocusedPanePlan() != nil + } + + func closeOtherTabsInFocusedPaneWithConfirmation() { + guard let plan = closeOtherTabsInFocusedPanePlan() else { return } + + let count = plan.panelIds.count + let titleLines = plan.titles.map { "• \($0)" }.joined(separator: "\n") + let message = if count == 1 { + String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)") + } else { + String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)") + } + guard confirmClose( + title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), + message: message, + acceptCmdD: false + ) else { return } + + for panelId in plan.panelIds { + _ = plan.workspace.closePanel(panelId, force: true) + } + } + func closeCurrentWorkspaceWithConfirmation() { #if DEBUG UITestRecorder.incrementInt("closeTabInvocations") @@ -777,8 +1401,8 @@ class TabManager: ObservableObject { alert.messageText = title alert.informativeText = message alert.alertStyle = .warning - alert.addButton(withTitle: "Close") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) // macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save"). // We only opt into this for the "close last workspace => close window" path to avoid @@ -794,12 +1418,60 @@ class TabManager: ObservableObject { return alert.runModal() == .alertFirstButtonReturn } + private struct CloseOtherTabsInFocusedPanePlan { + let workspace: Workspace + let panelIds: [UUID] + let titles: [String] + } + + private func closeOtherTabsInFocusedPanePlan() -> CloseOtherTabsInFocusedPanePlan? { + guard let workspace = selectedWorkspace else { return nil } + guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { + return nil + } + + let tabsInPane = workspace.bonsplitController.tabs(inPane: paneId) + guard !tabsInPane.isEmpty else { return nil } + guard let selectedTabId = workspace.bonsplitController.selectedTab(inPane: paneId)?.id ?? tabsInPane.first?.id else { + return nil + } + + var targetPanelIds: [UUID] = [] + var targetTitles: [String] = [] + for tab in tabsInPane where tab.id != selectedTabId { + guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } + if workspace.isPanelPinned(panelId) { + continue + } + targetPanelIds.append(panelId) + targetTitles.append(closeOtherTabsDisplayTitle(workspace.panelTitle(panelId: panelId))) + } + + guard !targetPanelIds.isEmpty else { return nil } + return CloseOtherTabsInFocusedPanePlan( + workspace: workspace, + panelIds: targetPanelIds, + titles: targetTitles + ) + } + + private func closeOtherTabsDisplayTitle(_ title: String?) -> String { + let collapsed = title? + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + if let collapsed, !collapsed.isEmpty { + return collapsed + } + return String(localized: "tab.untitled", defaultValue: "Untitled Tab") + } + private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) { let willCloseWindow = tabs.count <= 1 if workspaceNeedsConfirmClose(workspace), !confirmClose( - title: "Close workspace?", - message: "This will close the workspace and all of its panels.", + title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), + message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."), acceptCmdD: willCloseWindow ) { return @@ -813,22 +1485,54 @@ class TabManager: ObservableObject { } private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) { + let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in + partial + tab.bonsplitController.tabs(inPane: paneId).count + } + let panelKind: String = { + guard let panel = tab.panels[panelId] else { return "missing" } + if panel is TerminalPanel { return "terminal" } + if panel is BrowserPanel { return "browser" } + return String(describing: type(of: panel)) + }() +#if DEBUG + dlog( + "surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " + + "panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)" + ) +#endif + // Cmd+W closes the focused Bonsplit tab (a "tab" in the UI). When the workspace only has // a single tab left, closing it should close the workspace (and possibly the window), // rather than creating a replacement terminal. - let isLastTabInWorkspace = tab.panels.count <= 1 + let effectiveSurfaceCount = max(tab.panels.count, bonsplitTabCount) + let isLastTabInWorkspace = effectiveSurfaceCount <= 1 if isLastTabInWorkspace { let willCloseWindow = tabs.count <= 1 let needsConfirm = workspaceNeedsConfirmClose(tab) if needsConfirm { let message = willCloseWindow - ? "This will close the last tab and close the window." - : "This will close the last tab and close its workspace." + ? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.") + : String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.") +#if DEBUG + dlog( + "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=lastTab" + ) +#endif guard confirmClose( - title: "Close tab?", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), message: message, acceptCmdD: willCloseWindow - ) else { return } + ) else { +#if DEBUG + dlog( + "surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=lastTabConfirmDismissed" + ) +#endif + return + } } AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id) @@ -842,15 +1546,36 @@ class TabManager: ObservableObject { if let terminalPanel = tab.terminalPanel(for: panelId), terminalPanel.needsConfirmClose() { +#if DEBUG + dlog( + "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=terminalNeedsConfirm" + ) +#endif guard confirmClose( - title: "Close tab?", - message: "This will close the current tab.", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), acceptCmdD: false - ) else { return } + ) else { +#if DEBUG + dlog( + "surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=terminalConfirmDismissed" + ) +#endif + return + } } // We already confirmed (if needed); bypass Bonsplit's delegate gating. - tab.closePanel(panelId, force: true) + let closed = tab.closePanel(panelId, force: true) +#if DEBUG + dlog( + "surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) " + + "panelsAfterCall=\(tab.panels.count)" + ) +#endif } func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) { @@ -867,8 +1592,8 @@ class TabManager: ObservableObject { if let terminalPanel = tab.terminalPanel(for: surfaceId), terminalPanel.needsConfirmClose() { guard confirmClose( - title: "Close tab?", - message: "This will close the current tab.", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), acceptCmdD: false ) else { return } } @@ -883,11 +1608,24 @@ class TabManager: ObservableObject { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } +#if DEBUG + dlog( + "surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " + + "surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)" + ) +#endif + // Keep AppKit first responder in sync with workspace focus before routing the close. // If split reparenting caused a temporary model/view mismatch, fallback close logic in // Workspace.closePanel uses focused selection to resolve the correct tab deterministically. reconcileFocusedPanelFromFirstResponderForKeyboard() - _ = tab.closePanel(surfaceId, force: true) + let closed = tab.closePanel(surfaceId, force: true) +#if DEBUG + dlog( + "surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " + + "surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)" + ) +#endif AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId) } @@ -899,6 +1637,13 @@ class TabManager: ObservableObject { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } +#if DEBUG + dlog( + "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + + "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)" + ) +#endif + // Child-exit on the last panel should collapse the workspace, matching explicit close // semantics (and close the window when it was the last workspace). if tab.panels.count <= 1 { @@ -1115,19 +1860,37 @@ class TabManager: ObservableObject { guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) + _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) } private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } - guard AppFocusState.isAppActive() else { return } - guard let notificationStore = AppDelegate.shared?.notificationStore else { return } - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return } - if let tab = tabs.first(where: { $0.id == tabId }) { + _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + } + + @discardableResult + func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool { + dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true) + } + + @discardableResult + private func dismissNotificationIfActive( + tabId: UUID, + surfaceId: UUID?, + triggerFlash: Bool + ) -> Bool { + guard selectedTabId == tabId else { return false } + guard AppFocusState.isAppActive() else { return false } + guard let notificationStore = AppDelegate.shared?.notificationStore else { return false } + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } + if triggerFlash, + let panelId = surfaceId, + let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } - notificationStore.markRead(forTabId: tabId, surfaceId: panelId) + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) + return true } private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) { @@ -1181,8 +1944,8 @@ class TabManager: ObservableObject { private func updateWindowTitle(for tab: Workspace?) { let title = windowTitle(for: tab) - let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first - targetWindow?.title = title + guard let targetWindow = window else { return } + targetWindow.title = title } private func windowTitle(for tab: Workspace?) -> String { @@ -1196,7 +1959,11 @@ class TabManager: ObservableObject { } func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) { - guard tabs.contains(where: { $0.id == tabId }) else { return } + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + if let surfaceId, tab.panels[surfaceId] != nil { + // Keep selected-surface intent stable across selectedTabId didSet async restore. + lastFocusedPanelByTab[tabId] = surfaceId + } selectedTabId = tabId NotificationCenter.default.post( name: .ghosttyDidFocusTab, @@ -1204,10 +1971,15 @@ class TabManager: ObservableObject { userInfo: [GhosttyNotificationKey.tabId: tabId] ) - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + guard let self else { return } NSApp.activate(ignoringOtherApps: true) NSApp.unhide(nil) - if let window = NSApp.keyWindow ?? NSApp.windows.first { + if let app = AppDelegate.shared, + let windowId = app.windowId(for: self), + let window = app.mainWindow(for: windowId) { + window.makeKeyAndOrderFront(nil) + } else if let window = NSApp.keyWindow ?? NSApp.windows.first { window.makeKeyAndOrderFront(nil) } } @@ -1215,7 +1987,7 @@ class TabManager: ObservableObject { if let surfaceId { if !suppressFlash { focusSurface(tabId: tabId, surfaceId: surfaceId) - } else if let tab = tabs.first(where: { $0.id == tabId }) { + } else { tab.focusPanel(surfaceId) } } @@ -1401,6 +2173,7 @@ class TabManager: ObservableObject { /// Create a new terminal surface in the focused pane of the selected workspace func newSurface() { // Cmd+T should always focus the newly created surface. + selectedWorkspace?.clearSplitZoom() selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true) } @@ -1411,7 +2184,24 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return } - _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) +#if DEBUG + let directionLabel = direction.debugLabel + dlog( + "split.create.request kind=terminal dir=\(directionLabel) " + + "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + + "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" + ) +#endif + tab.clearSplitZoom() + sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) + let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) +#if DEBUG + dlog( + "split.create.result kind=terminal dir=\(directionLabel) " + + "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + + "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" + ) +#endif } /// Create a new browser split from the currently focused panel. @@ -1420,13 +2210,30 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return nil } - return newBrowserSplit( +#if DEBUG + let directionLabel = direction.debugLabel + dlog( + "split.create.request kind=browser dir=\(directionLabel) " + + "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + + "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" + ) +#endif + tab.clearSplitZoom() + let createdPanelId = newBrowserSplit( tabId: selectedTabId, fromPanelId: focusedPanelId, orientation: direction.orientation, insertFirst: direction.insertFirst, url: url ) +#if DEBUG + dlog( + "split.create.result kind=browser dir=\(directionLabel) " + + "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + + "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" + ) +#endif + return createdPanelId } /// Refresh Bonsplit right-side action button tooltips for all workspaces. @@ -1527,12 +2334,21 @@ class TabManager: ObservableObject { /// Returns the new panel's ID (which is also the surface ID for terminals) func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - return tab.newTerminalSplit( + let createdPanel = tab.newTerminalSplit( from: surfaceId, orientation: direction.orientation, insertFirst: direction.insertFirst, focus: focus )?.id +#if DEBUG + let directionLabel = direction.debugLabel + dlog( + "split.newSurface result dir=\(directionLabel) " + + "tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " + + "created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)" + ) +#endif + return createdPanel } /// Move focus in the specified direction @@ -1551,15 +2367,66 @@ class TabManager: ObservableObject { /// Equalize splits - not directly supported by bonsplit func equalizeSplits(tabId: UUID) -> Bool { - // Bonsplit doesn't have a built-in equalize feature - // This would require manually setting all divider positions to 0.5 - return false + guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + + var foundSplit = false + var allSucceeded = true + equalizeSplits( + in: tab.bonsplitController.treeSnapshot(), + controller: tab.bonsplitController, + foundSplit: &foundSplit, + allSucceeded: &allSucceeded + ) + return foundSplit && allSucceeded } - /// Toggle zoom on a panel - bonsplit doesn't have zoom support + /// Toggle zoom on a panel. func toggleSplitZoom(tabId: UUID, surfaceId: UUID) -> Bool { - // Bonsplit doesn't have zoom support - return false + guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + return tab.toggleSplitZoom(panelId: surfaceId) + } + + /// Toggle zoom for the currently focused panel in the selected workspace. + @discardableResult + func toggleFocusedSplitZoom() -> Bool { + guard let tab = selectedWorkspace, + let focusedPanelId = tab.focusedPanelId else { return false } + return tab.toggleSplitZoom(panelId: focusedPanelId) + } + + private func equalizeSplits( + in node: ExternalTreeNode, + controller: BonsplitController, + foundSplit: inout Bool, + allSucceeded: inout Bool + ) { + switch node { + case .pane: + return + case .split(let splitNode): + foundSplit = true + guard let splitId = UUID(uuidString: splitNode.id) else { + allSucceeded = false + return + } + + if !controller.setDividerPosition(0.5, forSplit: splitId) { + allSucceeded = false + } + + equalizeSplits( + in: splitNode.first, + controller: controller, + foundSplit: &foundSplit, + allSucceeded: &allSucceeded + ) + equalizeSplits( + in: splitNode.second, + controller: controller, + foundSplit: &foundSplit, + allSucceeded: &allSucceeded + ) + } } /// Close a surface/panel @@ -1607,19 +2474,81 @@ class TabManager: ObservableObject { return tab.browserPanel(for: panelId) } + /// Open a browser in a specific workspace, optionally preferring a split-right layout. + @discardableResult + func openBrowser( + inWorkspace tabId: UUID, + url: URL? = nil, + preferSplitRight: Bool = false, + insertAtEnd: Bool = false + ) -> UUID? { + guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil } + if selectedTabId != tabId { + selectedTabId = tabId + } + + if preferSplitRight { + if let targetPaneId = workspace.topRightBrowserReusePane(), + let browserPanel = workspace.newBrowserSurface( + inPane: targetPaneId, + url: url, + focus: true, + insertAtEnd: insertAtEnd + ) { + rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) + return browserPanel.id + } + + let splitSourcePanelId: UUID? = { + if let focusedPanelId = workspace.focusedPanelId, + workspace.panels[focusedPanelId] != nil { + return focusedPanelId + } + if let rememberedPanelId = lastFocusedPanelByTab[tabId], + workspace.panels[rememberedPanelId] != nil { + return rememberedPanelId + } + if let orderedPanelId = workspace.sidebarOrderedPanelIds().first(where: { workspace.panels[$0] != nil }) { + return orderedPanelId + } + return workspace.panels.keys.sorted { $0.uuidString < $1.uuidString }.first + }() + + if let splitSourcePanelId, + let browserPanel = workspace.newBrowserSplit( + from: splitSourcePanelId, + orientation: .horizontal, + url: url, + focus: true + ) { + rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) + return browserPanel.id + } + } + + guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first, + let browserPanel = workspace.newBrowserSurface( + inPane: paneId, + url: url, + focus: true, + insertAtEnd: insertAtEnd + ) else { + return nil + } + rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) + return browserPanel.id + } + /// Open a browser in the currently focused pane (as a new surface) @discardableResult func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { - guard let tabId = selectedTabId, - let tab = tabs.first(where: { $0.id == tabId }), - let focusedPaneId = tab.bonsplitController.focusedPaneId else { return nil } - let panel = tab.newBrowserSurface( - inPane: focusedPaneId, + guard let tabId = selectedTabId else { return nil } + return openBrowser( + inWorkspace: tabId, url: url, - focus: true, + preferSplitRight: false, insertAtEnd: insertAtEnd ) - return panel?.id } /// Reopen the most recently closed browser panel (Cmd+Shift+T). @@ -1779,6 +2708,36 @@ class TabManager: ObservableObject { } #if DEBUG + @MainActor + private func waitForTerminalPanelReadyForUITest( + tab: Workspace, + panelId: UUID, + timeoutSeconds: TimeInterval = 6.0 + ) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) { + let deadline = Date().addingTimeInterval(timeoutSeconds) + var attached = false + var hasSurface = false + var firstResponder = false + + while Date() < deadline { + guard let panel = tab.terminalPanel(for: panelId) else { + return (false, false, false) + } + + panel.surface.requestBackgroundSurfaceStartIfNeeded() + attached = panel.hostedView.window != nil + hasSurface = panel.surface.surface != nil + firstResponder = panel.hostedView.isSurfaceViewFirstResponder() + + if attached, hasSurface { + return (attached, hasSurface, firstResponder) + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + + return (attached, hasSurface, firstResponder) + } + private func setupUITestFocusShortcutsIfNeeded() { guard !didSetupUITestFocusShortcuts else { return } didSetupUITestFocusShortcuts = true @@ -1830,22 +2789,21 @@ class TabManager: ObservableObject { return } - var readyTerminal: TerminalPanel? - for _ in 0..<20 { - if let terminal = tab.focusedTerminalPanel, - terminal.hostedView.window != nil, - terminal.surface.surface != nil { - readyTerminal = terminal - break - } - try? await Task.sleep(nanoseconds: 50_000_000) + guard let topLeftPanelId = tab.focusedPanelId else { + self.writeSplitCloseRightTestData(["setupError": "Missing initial focused panel"], at: path) + return } + let initialTerminalReadiness = await self.waitForTerminalPanelReadyForUITest( + tab: tab, + panelId: topLeftPanelId + ) - guard let terminal = readyTerminal else { - let maybeTerminal = tab.focusedTerminalPanel + guard initialTerminalReadiness.attached, + initialTerminalReadiness.hasSurface, + let terminal = tab.terminalPanel(for: topLeftPanelId) else { self.writeSplitCloseRightTestData([ - "preTerminalAttached": (maybeTerminal?.hostedView.window != nil) ? "1" : "0", - "preTerminalSurfaceNil": (maybeTerminal?.surface.surface == nil) ? "1" : "0", + "preTerminalAttached": initialTerminalReadiness.attached ? "1" : "0", + "preTerminalSurfaceNil": initialTerminalReadiness.hasSurface ? "0" : "1", "setupError": "Initial terminal not ready (not attached or surface nil)" ], at: path) return @@ -1856,11 +2814,6 @@ class TabManager: ObservableObject { "preTerminalSurfaceNil": terminal.surface.surface == nil ? "1" : "0" ], at: path) - guard let topLeftPanelId = tab.focusedPanelId else { - self.writeSplitCloseRightTestData(["setupError": "Missing initial focused panel"], at: path) - return - } - if visualMode { // Visual repro mode: repeat the split/close sequence many times and write // screenshots to `shotsDir`. This avoids relying on XCUITest to click hover-only @@ -1996,7 +2949,7 @@ class TabManager: ObservableObject { continue } terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh() + terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry") } } @@ -2478,6 +3431,10 @@ class TabManager: ObservableObject { let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1" let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") .trimmingCharacters(in: .whitespacesAndNewlines) + let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" + let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d" + let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger + let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") .trimmingCharacters(in: .whitespacesAndNewlines) let expectedPanelsAfter = max( @@ -2616,7 +3573,36 @@ class TabManager: ObservableObject { } tab.focusPanel(exitPanelId) - try? await Task.sleep(nanoseconds: 100_000_000) + // Keep child-exit keyboard tests deterministic across user shell configs. + // `exec cat` exits on a single Ctrl+D and avoids ignore-eof shell settings. + if let exitPanel = tab.terminalPanel(for: exitPanelId) { + exitPanel.sendText("exec cat\r") + } + + var exitPanelAttachedBeforeCtrlD = false + var exitPanelHasSurfaceBeforeCtrlD = false + if !useEarlyTrigger { + let readiness = await self.waitForTerminalPanelReadyForUITest( + tab: tab, + panelId: exitPanelId + ) + exitPanelAttachedBeforeCtrlD = readiness.attached + exitPanelHasSurfaceBeforeCtrlD = readiness.hasSurface + if !(readiness.attached && readiness.hasSurface) { + write([ + "exitPanelAttachedBeforeCtrlD": readiness.attached ? "1" : "0", + "exitPanelHasSurfaceBeforeCtrlD": readiness.hasSurface ? "1" : "0", + "setupError": "Exit panel not ready for Ctrl+D (not attached or surface nil)", + "done": "1", + ]) + return + } + self.ensureFocusedTerminalFirstResponder() + try? await Task.sleep(nanoseconds: 80_000_000) + } else if let exitPanel = tab.terminalPanel(for: exitPanelId) { + exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil + exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil + } let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? "" let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in @@ -2637,6 +3623,8 @@ class TabManager: ObservableObject { "expectedPanelsAfter": String(expectedPanelsAfter), "focusedPanelBefore": focusedPanelBefore, "firstResponderPanelBefore": firstResponderPanelBefore, + "exitPanelAttachedBeforeCtrlD": exitPanelAttachedBeforeCtrlD ? "1" : "0", + "exitPanelHasSurfaceBeforeCtrlD": exitPanelHasSurfaceBeforeCtrlD ? "1" : "0", "ready": "1", "done": "0", ]) @@ -2720,33 +3708,48 @@ class TabManager: ObservableObject { return } - // Wait for the target panel to be fully attached after split churn. - let readyDeadline = Date().addingTimeInterval(2.0) + let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift + ? [.control, .shift] + : [.control] + let shouldWaitForSurface = !useEarlyTrigger + var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false - while Date() < readyDeadline { - guard let panel = tab.terminalPanel(for: exitPanelId) else { - write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) - return + if shouldWaitForSurface { + // Wait for the target panel to be fully attached after split churn. + let readyDeadline = Date().addingTimeInterval(5.0) + while Date() < readyDeadline { + guard let panel = tab.terminalPanel(for: exitPanelId) else { + write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) + return + } + panel.surface.requestBackgroundSurfaceStartIfNeeded() + attachedBeforeTrigger = panel.hostedView.window != nil + hasSurfaceBeforeTrigger = panel.surface.surface != nil + if attachedBeforeTrigger, hasSurfaceBeforeTrigger { + break + } + try? await Task.sleep(nanoseconds: 50_000_000) } + } else if let panel = tab.terminalPanel(for: exitPanelId) { attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil - if attachedBeforeTrigger, hasSurfaceBeforeTrigger { - break - } - try? await Task.sleep(nanoseconds: 50_000_000) } write([ "exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0", "exitPanelHasSurfaceBeforeTrigger": hasSurfaceBeforeTrigger ? "1" : "0", ]) + if shouldWaitForSurface && !(attachedBeforeTrigger && hasSurfaceBeforeTrigger) { + write(["autoTriggerError": "exitPanelNotReadyBeforeTrigger"]) + return + } guard let panel = tab.terminalPanel(for: exitPanelId) else { write(["autoTriggerError": "missingExitPanelAtTrigger"]) return } // Exercise the real key path (ghostty_surface_key for Ctrl+D). - if panel.hostedView.sendSyntheticCtrlDForUITest() { + if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey1": "1"]) } else { write([ @@ -2758,13 +3761,20 @@ class TabManager: ObservableObject { // In strict mode, never mask routing bugs with fallback writes. if strictKeyOnly { - write(["autoTriggerMode": "strict_ctrl_d"]) + let strictModeLabel: String = { + if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } + if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" } + if triggerUsesShift { return "strict_ctrl_shift_d" } + return "strict_ctrl_d" + }() + write(["autoTriggerMode": strictModeLabel]) return } // Non-strict mode keeps one additional Ctrl+D retry for startup timing variance. try? await Task.sleep(nanoseconds: 450_000_000) - if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() { + if tab.panels[exitPanelId] != nil, + panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey2": "1"]) } } @@ -2774,6 +3784,130 @@ class TabManager: ObservableObject { #endif } +extension TabManager { + func sessionAutosaveFingerprint() -> Int { + var hasher = Hasher() + hasher.combine(selectedTabId) + hasher.combine(tabs.count) + + for workspace in tabs.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) { + hasher.combine(workspace.id) + hasher.combine(workspace.focusedPanelId) + hasher.combine(workspace.currentDirectory) + hasher.combine(workspace.customTitle ?? "") + hasher.combine(workspace.customColor ?? "") + hasher.combine(workspace.isPinned) + hasher.combine(workspace.panels.count) + hasher.combine(workspace.statusEntries.count) + hasher.combine(workspace.metadataBlocks.count) + hasher.combine(workspace.logEntries.count) + hasher.combine(workspace.panelDirectories.count) + hasher.combine(workspace.panelTitles.count) + hasher.combine(workspace.panelPullRequests.count) + hasher.combine(workspace.panelGitBranches.count) + hasher.combine(workspace.surfaceListeningPorts.count) + + if let progress = workspace.progress { + hasher.combine(Int((progress.value * 1000).rounded())) + hasher.combine(progress.label) + } else { + hasher.combine(-1) + } + + if let gitBranch = workspace.gitBranch { + hasher.combine(gitBranch.branch) + hasher.combine(gitBranch.isDirty) + } else { + hasher.combine("") + hasher.combine(false) + } + } + + return hasher.finalize() + } + + func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { + let workspaceSnapshots = tabs + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } + let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in + tabs.firstIndex(where: { $0.id == selectedTabId }) + } + return SessionTabManagerSnapshot( + selectedWorkspaceIndex: selectedWorkspaceIndex, + workspaces: workspaceSnapshots + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) { + for tab in tabs { + unwireClosedBrowserTracking(for: tab) + } + + // Clear non-@Published state without touching tabs/selectedTabId yet. + lastFocusedPanelByTab.removeAll() + pendingPanelTitleUpdates.removeAll() + tabHistory.removeAll() + historyIndex = -1 + isNavigatingHistory = false + pendingWorkspaceUnfocusTarget = nil + workspaceCycleCooldownTask?.cancel() + workspaceCycleCooldownTask = nil + isWorkspaceCycleHot = false + selectionSideEffectsGeneration &+= 1 + recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) + + // Build the new workspace list locally to avoid intermediate @Published + // emissions (empty tabs, nil selectedTabId) that can leave SwiftUI's + // mountedWorkspaceIds empty and cause a frozen blank launch state (#399). + var newTabs: [Workspace] = [] + let workspaceSnapshots = snapshot.workspaces + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + for workspaceSnapshot in workspaceSnapshots { + let ordinal = Self.nextPortOrdinal + Self.nextPortOrdinal += 1 + let workspace = Workspace( + title: workspaceSnapshot.processTitle, + workingDirectory: workspaceSnapshot.currentDirectory, + portOrdinal: ordinal + ) + workspace.restoreSessionSnapshot(workspaceSnapshot) + wireClosedBrowserTracking(for: workspace) + newTabs.append(workspace) + } + + if newTabs.isEmpty { + let ordinal = Self.nextPortOrdinal + Self.nextPortOrdinal += 1 + let fallback = Workspace(title: "Terminal 1", portOrdinal: ordinal) + wireClosedBrowserTracking(for: fallback) + newTabs.append(fallback) + } + + // Determine selection before mutating @Published properties. + let newSelectedId: UUID? + if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex, + newTabs.indices.contains(selectedWorkspaceIndex) { + newSelectedId = newTabs[selectedWorkspaceIndex].id + } else { + newSelectedId = newTabs.first?.id + } + + // Single atomic assignment of @Published properties so SwiftUI observers + // never see an intermediate state with empty tabs or nil selection. + tabs = newTabs + selectedTabId = newSelectedId + + if let selectedTabId { + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: selectedTabId] + ) + } + } +} + // MARK: - Direction Types for Backwards Compatibility /// Split direction for backwards compatibility with old API @@ -2793,6 +3927,15 @@ enum SplitDirection { var insertFirst: Bool { self == .left || self == .up } + + var debugLabel: String { + switch self { + case .left: return "left" + case .right: return "right" + case .up: return "up" + case .down: return "down" + } + } } /// Resize direction for backwards compatibility @@ -2801,15 +3944,26 @@ enum ResizeDirection { } extension Notification.Name { + static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested") + static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested") + static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested") + static let commandPaletteSubmitRequested = Notification.Name("cmux.commandPaletteSubmitRequested") + static let commandPaletteDismissRequested = Notification.Name("cmux.commandPaletteDismissRequested") + static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") + static let commandPaletteRenameWorkspaceRequested = Notification.Name("cmux.commandPaletteRenameWorkspaceRequested") + static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection") + static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested") + static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested") + static let feedbackComposerRequested = Notification.Name("cmux.feedbackComposerRequested") static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface") + static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView") static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar") static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection") static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") - static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3f61f26b..6a708ae2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8,12 +8,43 @@ import WebKit /// Allows automated testing and external control of terminal tabs @MainActor class TerminalController { + struct SocketListenerHealth: Sendable { + let isRunning: Bool + let acceptLoopAlive: Bool + let socketPathMatches: Bool + let socketPathExists: Bool + let socketProbePerformed: Bool + let socketConnectable: Bool? + let socketConnectErrno: Int32? + + var failureSignals: [String] { + var signals: [String] = [] + if !isRunning { signals.append("not_running") } + if !acceptLoopAlive { signals.append("accept_loop_dead") } + if !socketPathMatches { signals.append("socket_path_mismatch") } + if !socketPathExists { signals.append("socket_missing") } + if socketProbePerformed && isRunning && acceptLoopAlive && socketPathMatches && socketPathExists && socketConnectable == false { + signals.append("socket_unreachable") + } + return signals + } + + var isHealthy: Bool { + failureSignals.isEmpty + } + } + static let shared = TerminalController() private nonisolated(unsafe) var socketPath = "/tmp/cmux.sock" private nonisolated(unsafe) var serverSocket: Int32 = -1 private nonisolated(unsafe) var isRunning = false private nonisolated(unsafe) var acceptLoopAlive = false + private nonisolated(unsafe) var activeAcceptLoopGeneration: UInt64 = 0 + private nonisolated(unsafe) var nextAcceptLoopGeneration: UInt64 = 0 + private nonisolated(unsafe) var pendingAcceptLoopRearmGeneration: UInt64? + private nonisolated(unsafe) var listenerStartInProgress = false + private nonisolated let listenerStateLock = NSLock() private var clientHandlers: [Int32: Thread] = [:] private var tabManager: TabManager? private var accessMode: SocketControlMode = .cmuxOnly @@ -21,6 +52,28 @@ class TerminalController { private nonisolated(unsafe) static var socketCommandPolicyDepth: Int = 0 private nonisolated(unsafe) static var socketCommandFocusAllowanceStack: [Bool] = [] private nonisolated static let socketCommandPolicyLock = NSLock() + private nonisolated static let socketListenBacklog: Int32 = 128 + private nonisolated static let acceptFailureBaseBackoffMs = 10 + private nonisolated static let acceptFailureMaxBackoffMs = 5_000 + private nonisolated static let acceptFailureMinimumRearmDelayMs = 100 + private nonisolated static let acceptFailureRearmThreshold = 50 + private nonisolated static let socketProbePollTimeoutMs: Int32 = 100 + private nonisolated static let socketProbePollAttempts = 3 + private nonisolated static let socketProbePollRetryBackoffUs: useconds_t = 50_000 + private nonisolated static let unixSocketPathMaxLength: Int = { + var addr = sockaddr_un() + // Reserve one byte for the null terminator. + return MemoryLayout.size(ofValue: addr.sun_path) - 1 + }() + + private struct ListenerStateSnapshot { + let socketPath: String + let serverSocket: Int32 + let isRunning: Bool + let acceptLoopAlive: Bool + let activeGeneration: UInt64 + let pendingRearmGeneration: UInt64? + } private static let focusIntentV1Commands: Set<String> = [ "focus_window", @@ -45,6 +98,7 @@ class TerminalController { "browser.focus_webview", "browser.focus", "browser.tab.switch", + "debug.command_palette.toggle", "debug.notification.focus", "debug.app.activate" ] @@ -87,6 +141,13 @@ class TerminalController { let responder: (_ accept: Bool, _ text: String?) -> Void } + private final class V2BrowserUndefinedSentinel {} + + private static let v2BrowserEvalEnvelopeTypeKey = "__cmux_t" + private static let v2BrowserEvalEnvelopeValueKey = "__cmux_v" + private static let v2BrowserEvalEnvelopeTypeUndefined = "undefined" + private static let v2BrowserEvalEnvelopeTypeValue = "value" + private var v2BrowserNextElementOrdinal: Int = 1 private var v2BrowserElementRefs: [String: V2BrowserElementRefEntry] = [:] private var v2BrowserFrameSelectorBySurface: [UUID: String] = [:] @@ -95,9 +156,35 @@ class TerminalController { private var v2BrowserDialogQueueBySurface: [UUID: [V2BrowserPendingDialog]] = [:] private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:] private var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:] + private let v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel() private init() {} + private nonisolated func withListenerState<T>(_ body: () -> T) -> T { + listenerStateLock.lock() + defer { listenerStateLock.unlock() } + return body() + } + + private nonisolated func listenerStateSnapshot() -> ListenerStateSnapshot { + withListenerState { + ListenerStateSnapshot( + socketPath: socketPath, + serverSocket: serverSocket, + isRunning: isRunning, + acceptLoopAlive: acceptLoopAlive, + activeGeneration: activeAcceptLoopGeneration, + pendingRearmGeneration: pendingAcceptLoopRearmGeneration + ) + } + } + + private nonisolated func shouldContinueAcceptLoop(generation: UInt64) -> Bool { + withListenerState { + isRunning && generation == activeAcceptLoopGeneration + } + } + nonisolated static func shouldSuppressSocketCommandActivation() -> Bool { socketCommandPolicyLock.lock() defer { socketCommandPolicyLock.unlock() } @@ -165,10 +252,29 @@ class TerminalController { key: String, value: String, icon: String?, - color: String? + color: String?, + url: URL?, + priority: Int, + format: SidebarMetadataFormat ) -> Bool { guard let current else { return true } - return current.key != key || current.value != value || current.icon != icon || current.color != color + return current.key != key || + current.value != value || + current.icon != icon || + current.color != color || + current.url != url || + current.priority != priority || + current.format != format + } + + nonisolated static func shouldReplaceMetadataBlock( + current: SidebarMetadataBlock?, + key: String, + markdown: String, + priority: Int + ) -> Bool { + guard let current else { return true } + return current.key != key || current.markdown != markdown || current.priority != priority } nonisolated static func shouldReplaceProgress( @@ -189,6 +295,17 @@ class TerminalController { return current.branch != branch || current.isDirty != isDirty } + nonisolated static func shouldReplacePullRequest( + current: SidebarPullRequestState?, + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus + ) -> Bool { + guard let current else { return true } + return current.number != number || current.label != label || current.url != url || current.status != status + } + nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { let currentSorted = Array(Set(current ?? [])).sorted() let nextSorted = Array(Set(next)).sorted() @@ -305,64 +422,311 @@ class TerminalController { return info.kp_eproc.e_ppid } + private nonisolated func socketListenerEventData( + stage: String, + errnoCode: Int32? = nil, + extra: [String: Any] = [:] + ) -> [String: Any] { + let snapshot = listenerStateSnapshot() + var data: [String: Any] = [ + "stage": stage, + "path": snapshot.socketPath, + "isRunning": snapshot.isRunning ? 1 : 0, + "acceptLoopAlive": snapshot.acceptLoopAlive ? 1 : 0, + "serverSocket": Int(snapshot.serverSocket), + "activeGeneration": snapshot.activeGeneration + ] + if let errnoCode { + data["errno"] = Int(errnoCode) + data["errnoDescription"] = String(cString: strerror(errnoCode)) + } + for (key, value) in extra { + data[key] = value + } + return data + } + + private nonisolated func reportSocketListenerFailure( + message: String, + stage: String, + errnoCode: Int32? = nil, + extra: [String: Any] = [:] + ) { + let data = socketListenerEventData(stage: stage, errnoCode: errnoCode, extra: extra) + sentryBreadcrumb(message, category: "socket", data: data) + sentryCaptureError(message, category: "socket", data: data, contextKey: "socket_listener") + } + + nonisolated static func acceptErrorClassification(errnoCode: Int32) -> String { + switch errnoCode { + case EINTR, ECONNABORTED, EAGAIN, EWOULDBLOCK: + return "immediate_retry" + case EMFILE, ENFILE, ENOBUFS, ENOMEM: + return "resource_pressure" + case EBADF, EINVAL, ENOTSOCK: + return "fatal" + default: + return "retry_with_backoff" + } + } + + nonisolated static func shouldRearmListenerForAcceptError(errnoCode: Int32) -> Bool { + acceptErrorClassification(errnoCode: errnoCode) == "fatal" + } + + nonisolated static func shouldRetryAcceptImmediately(errnoCode: Int32) -> Bool { + acceptErrorClassification(errnoCode: errnoCode) == "immediate_retry" + } + + nonisolated static func shouldRearmForConsecutiveAcceptFailures(consecutiveFailures: Int) -> Bool { + consecutiveFailures >= acceptFailureRearmThreshold + } + + nonisolated static func acceptFailureBackoffMilliseconds(consecutiveFailures: Int) -> Int { + guard consecutiveFailures > 0 else { return 0 } + var delay = acceptFailureBaseBackoffMs + var remaining = consecutiveFailures - 1 + while remaining > 0 { + if delay >= acceptFailureMaxBackoffMs { + return acceptFailureMaxBackoffMs + } + delay = min(delay * 2, acceptFailureMaxBackoffMs) + remaining -= 1 + } + return delay + } + + nonisolated static func acceptFailureRearmDelayMilliseconds(consecutiveFailures: Int) -> Int { + max( + acceptFailureBackoffMilliseconds(consecutiveFailures: consecutiveFailures), + acceptFailureMinimumRearmDelayMs + ) + } + + nonisolated static func shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: Int) -> Bool { + guard consecutiveFailures > 0 else { return false } + if consecutiveFailures <= 3 { + return true + } + return (consecutiveFailures & (consecutiveFailures - 1)) == 0 + } + + nonisolated static func shouldUnlinkSocketPathAfterAcceptLoopCleanup( + pathMatches: Bool, + isRunning: Bool, + activeGeneration: UInt64, + listenerStartInProgress: Bool + ) -> Bool { + guard pathMatches else { return false } + guard !listenerStartInProgress else { return false } + return !isRunning && activeGeneration == 0 + } + + private nonisolated static func unixSocketAddress(path: String) -> sockaddr_un? { + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLength = unixSocketPathMaxLength + 1 + var didFit = false + path.withCString { source in + let sourceLength = strlen(source) + guard sourceLength < maxLength else { return } + + _ = withUnsafeMutableBytes(of: &addr.sun_path) { buffer in + buffer.initializeMemory(as: UInt8.self, repeating: 0) + } + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let destination = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(destination, source, maxLength - 1) + } + didFit = true + } + return didFit ? addr : nil + } + + private nonisolated static func bindUnixSocket(_ socket: Int32, path: String) -> Int32? { + guard var addr = unixSocketAddress(path: path) else { return nil } + return withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(socket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) + } + } + } + + private nonisolated static func probeSocketConnectability(path: String) -> (isConnectable: Bool?, errnoCode: Int32?) { + let probeSocket = socket(AF_UNIX, SOCK_STREAM, 0) + guard probeSocket >= 0 else { + return (false, errno) + } + defer { close(probeSocket) } + + let existingFlags = fcntl(probeSocket, F_GETFL, 0) + if existingFlags >= 0 { + _ = fcntl(probeSocket, F_SETFL, existingFlags | O_NONBLOCK) + } + + guard var addr = unixSocketAddress(path: path) else { + return (false, ENAMETOOLONG) + } + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(probeSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) + } + } + if connectResult == 0 { + return (true, nil) + } + let connectErrno = errno + if connectErrno == EINPROGRESS { + var pollDescriptor = pollfd(fd: probeSocket, events: Int16(POLLOUT), revents: 0) + for attempt in 0..<Self.socketProbePollAttempts { + pollDescriptor.revents = 0 + let pollResult = poll(&pollDescriptor, 1, Self.socketProbePollTimeoutMs) + if pollResult > 0 { + var socketError: Int32 = 0 + var socketErrorLength = socklen_t(MemoryLayout<Int32>.size) + let status = getsockopt( + probeSocket, + SOL_SOCKET, + SO_ERROR, + &socketError, + &socketErrorLength + ) + if status == 0 && socketError == 0 { + return (true, nil) + } + if status == 0 { + return (false, socketError) + } + return (false, errno) + } + + let pollErrno = errno + if pollResult == 0 || pollErrno == EINTR { + if attempt + 1 < Self.socketProbePollAttempts { + usleep(Self.socketProbePollRetryBackoffUs) + continue + } + return (false, pollResult == 0 ? ETIMEDOUT : pollErrno) + } + return (false, pollErrno) + } + } + return (false, connectErrno) + } + func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) { self.tabManager = tabManager self.accessMode = accessMode - if isRunning { - if self.socketPath == socketPath && acceptLoopAlive { - self.accessMode = accessMode - applySocketPermissions() - return - } + let existing = withListenerState { + (isRunning: isRunning, socketPath: self.socketPath, acceptLoopAlive: acceptLoopAlive) + } + + if existing.isRunning && existing.socketPath == socketPath && existing.acceptLoopAlive { + self.accessMode = accessMode + applySocketPermissions() + return + } + + if existing.isRunning { stop() } - self.socketPath = socketPath + withListenerState { + self.socketPath = socketPath + listenerStartInProgress = true + } + var listenerActivated = false + defer { + if !listenerActivated { + withListenerState { + listenerStartInProgress = false + } + } + } // Remove existing socket file unlink(socketPath) // Create socket - serverSocket = socket(AF_UNIX, SOCK_STREAM, 0) - guard serverSocket >= 0 else { + let newServerSocket = socket(AF_UNIX, SOCK_STREAM, 0) + guard newServerSocket >= 0 else { + let errnoCode = errno print("TerminalController: Failed to create socket") + reportSocketListenerFailure( + message: "socket.listener.start.failed", + stage: "create_socket", + errnoCode: errnoCode + ) return } // Bind to path - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - strcpy(pathBuf, ptr) - } - } - - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - bind(serverSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) - } + guard let bindResult = Self.bindUnixSocket(newServerSocket, path: socketPath) else { + close(newServerSocket) + reportSocketListenerFailure( + message: "socket.listener.start.failed", + stage: "bind_path_too_long", + errnoCode: ENAMETOOLONG, + extra: [ + "pathLength": socketPath.utf8.count, + "maxPathLength": Self.unixSocketPathMaxLength + ] + ) + return } guard bindResult >= 0 else { + let errnoCode = errno print("TerminalController: Failed to bind socket") - close(serverSocket) + close(newServerSocket) + reportSocketListenerFailure( + message: "socket.listener.start.failed", + stage: "bind", + errnoCode: errnoCode + ) return } applySocketPermissions() // Listen - guard listen(serverSocket, 5) >= 0 else { + guard listen(newServerSocket, Self.socketListenBacklog) >= 0 else { + let errnoCode = errno print("TerminalController: Failed to listen on socket") - close(serverSocket) + close(newServerSocket) + reportSocketListenerFailure( + message: "socket.listener.start.failed", + stage: "listen", + errnoCode: errnoCode + ) return } - isRunning = true + let generation = withListenerState { + isRunning = true + pendingAcceptLoopRearmGeneration = nil + nextAcceptLoopGeneration &+= 1 + let generation = nextAcceptLoopGeneration + activeAcceptLoopGeneration = generation + serverSocket = newServerSocket + listenerStartInProgress = false + return generation + } + listenerActivated = true + let listenerSocket = newServerSocket print("TerminalController: Listening on \(socketPath)") + sentryBreadcrumb( + "socket.listener.listening", + category: "socket", + data: [ + "path": socketPath, + "mode": accessMode.rawValue, + "generation": generation, + "backlog": Self.socketListenBacklog + ] + ) // Wire batched port scanner results back to workspace state. PortScanner.shared.onPortsUpdated = { [weak self] workspaceId, panelId, ports in @@ -385,23 +749,175 @@ class TerminalController { // Accept connections in background thread Thread.detachNewThread { [weak self] in - self?.acceptLoop() + self?.acceptLoop(listenerSocket: listenerSocket, generation: generation) } } - nonisolated func stop() { - isRunning = false - if serverSocket >= 0 { - close(serverSocket) - serverSocket = -1 + nonisolated func socketListenerHealth(expectedSocketPath: String) -> SocketListenerHealth { + let snapshot = listenerStateSnapshot() + let pathMatches = snapshot.socketPath == expectedSocketPath + + var st = stat() + let exists = lstat(expectedSocketPath, &st) == 0 && (st.st_mode & S_IFMT) == S_IFSOCK + let shouldProbeConnection = snapshot.isRunning && snapshot.acceptLoopAlive && pathMatches && exists + let connectability = shouldProbeConnection + ? Self.probeSocketConnectability(path: expectedSocketPath) + : (isConnectable: nil, errnoCode: nil) + + return SocketListenerHealth( + isRunning: snapshot.isRunning, + acceptLoopAlive: snapshot.acceptLoopAlive, + socketPathMatches: pathMatches, + socketPathExists: exists, + socketProbePerformed: shouldProbeConnection, + socketConnectable: connectability.isConnectable, + socketConnectErrno: connectability.errnoCode + ) + } + + nonisolated static func probeSocketCommand( + _ command: String, + at socketPath: String, + timeout: TimeInterval + ) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + +#if os(macOS) + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + ptr, + socklen_t(MemoryLayout<Int32>.size) + ) + } +#endif + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout<sockaddr_un>.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + let pathBytes = Array(socketPath.utf8CString) + guard pathBytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + memset(raw, 0, maxLen) + for index in 0..<pathBytes.count { + raw[index] = pathBytes[index] + } + } + + let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + pathBytes.count) +#if os(macOS) + addr.sun_len = UInt8(min(Int(addrLen), 255)) +#endif + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { return nil } + + let payload = command + "\n" + let wroteAll = payload.withCString { cString in + var remaining = strlen(cString) + var pointer = UnsafeRawPointer(cString) + while remaining > 0 { + let written = write(fd, pointer, remaining) + if written <= 0 { return false } + remaining -= written + pointer = pointer.advanced(by: written) + } + return true + } + guard wroteAll else { return nil } + + let deadline = Date().addingTimeInterval(timeout) + var buffer = [UInt8](repeating: 0, count: 4096) + var response = "" + + while Date() < deadline { + var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let ready = poll(&pollDescriptor, 1, 100) + if ready < 0 { + return nil + } + if ready == 0 { + continue + } + + let count = read(fd, &buffer, buffer.count) + if count <= 0 { + break + } + if let chunk = String(bytes: buffer[0..<count], encoding: .utf8) { + response.append(chunk) + if let newlineIndex = response.firstIndex(of: "\n") { + return String(response[..<newlineIndex]) + } + } + } + + let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + nonisolated func stop() { + let (socketToClose, socketPathToUnlink) = withListenerState { + isRunning = false + acceptLoopAlive = false + pendingAcceptLoopRearmGeneration = nil + listenerStartInProgress = false + nextAcceptLoopGeneration &+= 1 + activeAcceptLoopGeneration = 0 + let socketToClose = serverSocket + serverSocket = -1 + return (socketToClose, socketPath) + } + if socketToClose >= 0 { + close(socketToClose) + } + unlink(socketPathToUnlink) + } + + private nonisolated func unlinkSocketPathIfListenerStillInactive(_ path: String) { + let shouldUnlink = withListenerState { + Self.shouldUnlinkSocketPathAfterAcceptLoopCleanup( + pathMatches: socketPath == path, + isRunning: isRunning, + activeGeneration: activeAcceptLoopGeneration, + listenerStartInProgress: listenerStartInProgress + ) + } + if shouldUnlink { + unlink(path) } - unlink(socketPath) } private func applySocketPermissions() { let permissions = mode_t(accessMode.socketFilePermissions) - if chmod(socketPath, permissions) != 0 { - print("TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(socketPath)") + let currentSocketPath = withListenerState { socketPath } + if chmod(currentSocketPath, permissions) != 0 { + let errnoCode = errno + print( + "TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(currentSocketPath)" + ) + sentryBreadcrumb( + "socket.listener.permissions.failed", + category: "socket", + data: socketListenerEventData( + stage: "chmod", + errnoCode: errnoCode, + extra: ["permissions": String(permissions, radix: 8)] + ) + ) } } @@ -428,7 +944,7 @@ class TerminalController { guard lowered == "auth" || lowered.hasPrefix("auth ") else { return nil } - guard SocketControlPasswordStore.hasConfiguredPassword() else { + guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else { return "ERROR: Password mode is enabled but no socket password is configured in Settings." } @@ -441,7 +957,7 @@ class TerminalController { guard !provided.isEmpty else { return "ERROR: Missing password. Usage: auth <password>" } - guard SocketControlPasswordStore.verify(password: provided) else { + guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else { return "ERROR: Invalid password" } authenticated = true @@ -465,7 +981,7 @@ class TerminalController { return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password") } - guard SocketControlPasswordStore.hasConfiguredPassword() else { + guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else { return v2Error( id: id, code: "auth_unconfigured", @@ -473,7 +989,7 @@ class TerminalController { ) } - guard SocketControlPasswordStore.verify(password: provided) else { + guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else { return v2Error(id: id, code: "auth_failed", message: "Invalid password") } authenticated = true @@ -496,33 +1012,164 @@ class TerminalController { return nil } - private nonisolated func acceptLoop() { - acceptLoopAlive = true + private nonisolated func acceptLoop(listenerSocket: Int32, generation: UInt64) { + let armedAcceptLoop = withListenerState { + guard generation == activeAcceptLoopGeneration else { return false } + acceptLoopAlive = true + return true + } + guard armedAcceptLoop else { + return + } + + sentryBreadcrumb( + "socket.listener.accept_loop.started", + category: "socket", + data: socketListenerEventData( + stage: "accept_loop_start", + extra: [ + "generation": generation, + "listenerSocket": Int(listenerSocket) + ] + ) + ) + + var exitReason = "stopped" + var lastAcceptErrno: Int32? + var lastAcceptErrnoClass = "none" + var rearmRequested = false + defer { - acceptLoopAlive = false - isRunning = false + let cleanup = withListenerState { + guard generation == activeAcceptLoopGeneration else { + return (shouldCaptureExit: false, socketToClose: Int32(-1), pathToUnlink: nil as String?) + } + + if isRunning && exitReason == "stopped" { + exitReason = "unexpected_loop_exit" + } + let shouldCaptureExit = exitReason != "stopped" + + acceptLoopAlive = false + isRunning = false + activeAcceptLoopGeneration = 0 + + var socketToClose: Int32 = -1 + var pathToUnlink: String? + if serverSocket == listenerSocket { + socketToClose = serverSocket + serverSocket = -1 + if shouldCaptureExit { + pathToUnlink = socketPath + } + } + return (shouldCaptureExit, socketToClose, pathToUnlink) + } + + if cleanup.socketToClose >= 0 { + close(cleanup.socketToClose) + } + if let pathToUnlink = cleanup.pathToUnlink { + unlinkSocketPathIfListenerStillInactive(pathToUnlink) + } + + if cleanup.shouldCaptureExit { + let data = socketListenerEventData( + stage: "accept_loop_exit", + errnoCode: lastAcceptErrno, + extra: [ + "reason": exitReason, + "generation": generation, + "errnoClass": lastAcceptErrnoClass, + "rearmRequested": rearmRequested ? 1 : 0 + ] + ) + sentryBreadcrumb("socket.listener.accept_loop.exited", category: "socket", data: data) + sentryCaptureError( + "socket.listener.accept_loop.exited", + category: "socket", + data: data, + contextKey: "socket_listener" + ) + } } var consecutiveFailures = 0 - while isRunning { + + while shouldContinueAcceptLoop(generation: generation) { var clientAddr = sockaddr_un() var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size) let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - accept(serverSocket, sockaddrPtr, &clientAddrLen) + accept(listenerSocket, sockaddrPtr, &clientAddrLen) } } guard clientSocket >= 0 else { - if isRunning { - consecutiveFailures += 1 - print("TerminalController: Accept failed (\(consecutiveFailures) consecutive)") - if consecutiveFailures >= 50 { - print("TerminalController: Too many consecutive accept failures, exiting accept loop") - break + if !shouldContinueAcceptLoop(generation: generation) { + exitReason = "stopped" + break + } + + let errnoCode = errno + lastAcceptErrno = errnoCode + let errnoClass = Self.acceptErrorClassification(errnoCode: errnoCode) + lastAcceptErrnoClass = errnoClass + + if Self.shouldRetryAcceptImmediately(errnoCode: errnoCode) { + continue + } + + consecutiveFailures += 1 + let backoffMs = Self.acceptFailureBackoffMilliseconds( + consecutiveFailures: consecutiveFailures + ) + let rearmDelayMs = Self.acceptFailureRearmDelayMilliseconds( + consecutiveFailures: consecutiveFailures + ) + + if Self.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: consecutiveFailures) { + sentryBreadcrumb( + "socket.listener.accept.failed", + category: "socket", + data: socketListenerEventData( + stage: "accept", + errnoCode: errnoCode, + extra: [ + "consecutiveFailures": consecutiveFailures, + "generation": generation, + "errnoClass": errnoClass, + "backoffMs": backoffMs + ] + ) + ) + } + + let shouldRearmForFatalErrno = Self.shouldRearmListenerForAcceptError(errnoCode: errnoCode) + let shouldRearmForPersistentFailures = Self.shouldRearmForConsecutiveAcceptFailures( + consecutiveFailures: consecutiveFailures + ) + + if shouldRearmForFatalErrno || shouldRearmForPersistentFailures { + exitReason = shouldRearmForFatalErrno + ? "fatal_accept_error" + : "persistent_accept_failures" + rearmRequested = true + withListenerState { + pendingAcceptLoopRearmGeneration = generation } - usleep(10_000) // 10ms backoff + scheduleListenerRearm( + generation: generation, + errnoCode: errnoCode, + consecutiveFailures: consecutiveFailures, + delayMs: rearmDelayMs + ) + break + } + + if backoffMs > 0 { + usleep(useconds_t(backoffMs * 1_000)) } continue } @@ -541,6 +1188,43 @@ class TerminalController { } } + private nonisolated func scheduleListenerRearm( + generation: UInt64, + errnoCode: Int32, + consecutiveFailures: Int, + delayMs: Int + ) { + let deadline = DispatchTime.now() + .milliseconds(delayMs) + DispatchQueue.main.asyncAfter(deadline: deadline) { [weak self] in + guard let self else { return } + guard let tabManager = self.tabManager else { return } + guard let restartPath = self.withListenerState({ () -> String? in + guard self.pendingAcceptLoopRearmGeneration == generation else { return nil } + self.pendingAcceptLoopRearmGeneration = nil + return self.socketPath + }) else { return } + + let restartMode = self.accessMode + + sentryBreadcrumb( + "socket.listener.rearm.requested", + category: "socket", + data: self.socketListenerEventData( + stage: "accept_rearm", + errnoCode: errnoCode, + extra: [ + "generation": generation, + "consecutiveFailures": consecutiveFailures, + "rearmDelayMs": delayMs + ] + ) + ) + + self.stop() + self.start(tabManager: tabManager, socketPath: restartPath, accessMode: restartMode) + } + } + private func handleClient(_ socket: Int32, peerPid: pid_t? = nil) { defer { close(socket) } @@ -577,7 +1261,7 @@ class TerminalController { var pending = "" var authenticated = false - while isRunning { + while withListenerState({ isRunning }) { let bytesRead = read(socket, &buffer, buffer.count - 1) guard bytesRead > 0 else { break } @@ -695,7 +1379,7 @@ class TerminalController { return listNotifications() case "clear_notifications": - return clearNotifications() + return clearNotifications(args) case "set_app_focus": return setAppFocusOverride(args) @@ -706,12 +1390,30 @@ class TerminalController { case "set_status": return setStatus(args) + case "report_meta": + return reportMeta(args) + + case "report_meta_block": + return reportMetaBlock(args) + case "clear_status": return clearStatus(args) + case "clear_meta": + return clearMeta(args) + + case "clear_meta_block": + return clearMetaBlock(args) + case "list_status": return listStatus(args) + case "list_meta": + return listMeta(args) + + case "list_meta_blocks": + return listMetaBlocks(args) + case "log": return appendLog(args) @@ -733,6 +1435,15 @@ class TerminalController { case "clear_git_branch": return clearGitBranch(args) + case "report_pr": + return reportPullRequest(args) + + case "report_review": + return reportPullRequest(args) + + case "clear_pr": + return clearPullRequest(args) + case "report_ports": return reportPorts(args) @@ -759,6 +1470,9 @@ class TerminalController { #if DEBUG + case "send_workspace": + return sendInputToWorkspace(args) + case "set_shortcut": return setShortcut(args) @@ -974,6 +1688,8 @@ class TerminalController { case "system.identify": return v2Ok(id: id, result: v2Identify(params: params)) + case "system.tree": + return v2Result(id: id, self.v2SystemTree(params: params)) case "auth.login": return v2Ok( id: id, @@ -1021,6 +1737,16 @@ class TerminalController { case "workspace.last": return v2Result(id: id, self.v2WorkspaceLast(params: params)) + // Settings + case "settings.open": + return v2Result(id: id, self.v2SettingsOpen(params: params)) + + // Feedback + case "feedback.open": + return v2Result(id: id, self.v2FeedbackOpen(params: params)) + case "feedback.submit": + return v2Result(id: id, self.v2FeedbackSubmit(params: params)) + // Surfaces / input case "surface.list": @@ -1265,6 +1991,11 @@ class TerminalController { return v2Result(id: id, self.v2BrowserInputKeyboard(params: params)) case "browser.input_touch": return v2Result(id: id, self.v2BrowserInputTouch(params: params)) + + // Markdown + case "markdown.open": + return v2Result(id: id, self.v2MarkdownOpen(params: params)) + case "surface.read_text": return v2Result(id: id, self.v2SurfaceReadText(params: params)) @@ -1279,6 +2010,28 @@ class TerminalController { return v2Result(id: id, self.v2DebugType(params: params)) case "debug.app.activate": return v2Result(id: id, self.v2DebugActivateApp()) + case "debug.command_palette.toggle": + return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params)) + case "debug.command_palette.rename_tab.open": + return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params)) + case "debug.command_palette.visible": + return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params)) + case "debug.command_palette.selection": + return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params)) + case "debug.command_palette.results": + return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params)) + case "debug.command_palette.rename_input.interact": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params)) + case "debug.command_palette.rename_input.delete_backward": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params)) + case "debug.command_palette.rename_input.selection": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params)) + case "debug.command_palette.rename_input.select_all": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params)) + case "debug.browser.address_bar_focused": + return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params)) + case "debug.sidebar.visible": + return v2Result(id: id, self.v2DebugSidebarVisible(params: params)) case "debug.terminal.is_focused": return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params)) case "debug.terminal.read_text": @@ -1287,6 +2040,8 @@ class TerminalController { return v2Result(id: id, self.v2DebugRenderStats(params: params)) case "debug.layout": return v2Result(id: id, self.v2DebugLayout()) + case "debug.portal.stats": + return v2Result(id: id, self.v2DebugPortalStats()) case "debug.bonsplit_underflow.count": return v2Result(id: id, self.v2DebugBonsplitUnderflowCount()) case "debug.bonsplit_underflow.reset": @@ -1332,6 +2087,7 @@ class TerminalController { "system.ping", "system.capabilities", "system.identify", + "system.tree", "auth.login", "window.list", "window.current", @@ -1350,6 +2106,9 @@ class TerminalController { "workspace.next", "workspace.previous", "workspace.last", + "settings.open", + "feedback.open", + "feedback.submit", "surface.list", "surface.current", "surface.focus", @@ -1384,6 +2143,7 @@ class TerminalController { "notification.clear", "app.focus_override.set", "app.simulate_active", + "markdown.open", "browser.open_split", "browser.navigate", "browser.back", @@ -1475,10 +2235,22 @@ class TerminalController { "debug.shortcut.simulate", "debug.type", "debug.app.activate", + "debug.command_palette.toggle", + "debug.command_palette.rename_tab.open", + "debug.command_palette.visible", + "debug.command_palette.selection", + "debug.command_palette.results", + "debug.command_palette.rename_input.interact", + "debug.command_palette.rename_input.delete_backward", + "debug.command_palette.rename_input.selection", + "debug.command_palette.rename_input.select_all", + "debug.browser.address_bar_focused", + "debug.sidebar.visible", "debug.terminal.is_focused", "debug.terminal.read_text", "debug.terminal.render_stats", "debug.layout", + "debug.portal.stats", "debug.bonsplit_underflow.count", "debug.bonsplit_underflow.reset", "debug.empty_panel.count", @@ -1587,6 +2359,203 @@ class TerminalController { ] } + private func v2SystemTree(params: [String: Any]) -> V2CallResult { + let workspaceFilter = v2UUID(params, "workspace_id") + if params["workspace_id"] != nil && workspaceFilter == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let includeAllWindows = v2Bool(params, "all_windows") ?? false + + var identifyParams: [String: Any] = [:] + if let caller = params["caller"] as? [String: Any], !caller.isEmpty { + identifyParams["caller"] = caller + } + let identifyPayload = v2Identify(params: identifyParams) + let focused = identifyPayload["focused"] as? [String: Any] ?? [:] + let caller = identifyPayload["caller"] as? [String: Any] ?? [:] + let focusedWindowId = v2UUIDAny(focused["window_id"]) ?? v2UUIDAny(focused["window_ref"]) + + var windowNodes: [[String: Any]] = [] + var workspaceFound = (workspaceFilter == nil) + + v2MainSync { + guard let app = AppDelegate.shared else { return } + let summaries = app.listMainWindowSummaries() + let defaultWindowId = focusedWindowId ?? summaries.first?.windowId + + for (windowIndex, summary) in summaries.enumerated() { + guard let manager = app.tabManagerFor(windowId: summary.windowId) else { continue } + + if let workspaceFilter { + guard let workspaceIndex = manager.tabs.firstIndex(where: { $0.id == workspaceFilter }) else { + continue + } + let workspace = manager.tabs[workspaceIndex] + let workspaceNode = v2TreeWorkspaceNode( + workspace: workspace, + index: workspaceIndex, + selected: workspace.id == manager.selectedTabId + ) + windowNodes = [ + v2TreeWindowNode( + summary: summary, + index: windowIndex, + workspaceNodes: [workspaceNode] + ) + ] + workspaceFound = true + break + } + + if !includeAllWindows && summary.windowId != defaultWindowId { + continue + } + + let workspaceNodesForWindow = manager.tabs.enumerated().map { workspaceIndex, workspace in + v2TreeWorkspaceNode( + workspace: workspace, + index: workspaceIndex, + selected: workspace.id == manager.selectedTabId + ) + } + + windowNodes.append( + v2TreeWindowNode( + summary: summary, + index: windowIndex, + workspaceNodes: workspaceNodesForWindow + ) + ) + } + } + + if let workspaceFilter, !workspaceFound { + return .err( + code: "not_found", + message: "Workspace not found", + data: [ + "workspace_id": workspaceFilter.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceFilter) + ] + ) + } + + return .ok([ + "active": focused.isEmpty ? (NSNull() as Any) : focused, + "caller": caller.isEmpty ? (NSNull() as Any) : caller, + "windows": windowNodes + ]) + } + + private func v2TreeWindowNode( + summary: AppDelegate.MainWindowSummary, + index: Int, + workspaceNodes: [[String: Any]] + ) -> [String: Any] { + return [ + "id": summary.windowId.uuidString, + "ref": v2Ref(kind: .window, uuid: summary.windowId), + "index": index, + "key": summary.isKeyWindow, + "visible": summary.isVisible, + "workspace_count": workspaceNodes.count, + "selected_workspace_id": v2OrNull(summary.selectedWorkspaceId?.uuidString), + "selected_workspace_ref": v2Ref(kind: .workspace, uuid: summary.selectedWorkspaceId), + "workspaces": workspaceNodes + ] + } + + private func v2TreeWorkspaceNode( + workspace: Workspace, + index: Int, + selected: Bool + ) -> [String: Any] { + var paneByPanelId: [UUID: UUID] = [:] + var indexInPaneByPanelId: [UUID: Int] = [:] + var selectedInPaneByPanelId: [UUID: Bool] = [:] + + let paneIds = workspace.bonsplitController.allPaneIds + for paneId in paneIds { + let tabs = workspace.bonsplitController.tabs(inPane: paneId) + let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) + for (tabIndex, tab) in tabs.enumerated() { + guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } + paneByPanelId[panelId] = paneId.id + indexInPaneByPanelId[panelId] = tabIndex + selectedInPaneByPanelId[panelId] = (tab.id == selectedTab?.id) + } + } + + var surfacesByPane: [UUID: [[String: Any]]] = [:] + let focusedSurfaceId = workspace.focusedPanelId + for (surfaceIndex, panel) in orderedPanels(in: workspace).enumerated() { + let paneUUID = paneByPanelId[panel.id] + let selectedInPane = selectedInPaneByPanelId[panel.id] ?? false + + var item: [String: Any] = [ + "id": panel.id.uuidString, + "ref": v2Ref(kind: .surface, uuid: panel.id), + "index": surfaceIndex, + "type": panel.panelType.rawValue, + "title": workspace.panelTitle(panelId: panel.id) ?? panel.displayTitle, + "focused": panel.id == focusedSurfaceId, + "selected": selectedInPane, + "selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id]), + "pane_id": v2OrNull(paneUUID?.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), + "index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]) + ] + + if panel.panelType == .browser, let browserPanel = panel as? BrowserPanel { + item["url"] = browserPanel.currentURL?.absoluteString ?? "" + } else { + item["url"] = NSNull() + } + if let paneUUID { + surfacesByPane[paneUUID, default: []].append(item) + } + } + + for paneUUID in surfacesByPane.keys { + surfacesByPane[paneUUID]?.sort { + let lhs = ($0["index_in_pane"] as? Int) ?? ($0["index"] as? Int) ?? Int.max + let rhs = ($1["index_in_pane"] as? Int) ?? ($1["index"] as? Int) ?? Int.max + return lhs < rhs + } + } + + let focusedPaneId = workspace.bonsplitController.focusedPaneId + let panes: [[String: Any]] = paneIds.enumerated().map { paneIndex, paneId in + let tabs = workspace.bonsplitController.tabs(inPane: paneId) + let surfaceUUIDs: [UUID] = tabs.compactMap { workspace.panelIdFromSurfaceId($0.id) } + let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) + let selectedSurfaceUUID = selectedTab.flatMap { workspace.panelIdFromSurfaceId($0.id) } + + return [ + "id": paneId.id.uuidString, + "ref": v2Ref(kind: .pane, uuid: paneId.id), + "index": paneIndex, + "focused": paneId == focusedPaneId, + "surface_ids": surfaceUUIDs.map { $0.uuidString }, + "surface_refs": surfaceUUIDs.map { v2Ref(kind: .surface, uuid: $0) }, + "selected_surface_id": v2OrNull(selectedSurfaceUUID?.uuidString), + "selected_surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceUUID), + "surface_count": surfaceUUIDs.count, + "surfaces": surfacesByPane[paneId.id] ?? [] + ] + } + + return [ + "id": workspace.id.uuidString, + "ref": v2Ref(kind: .workspace, uuid: workspace.id), + "index": index, + "title": workspace.title, + "selected": selected, + "pinned": workspace.isPinned, + "panes": panes + ] + } + // MARK: - V2 Helpers (encoding + result plumbing) // MARK: - V2 Helpers (encoding + result plumbing) @@ -1925,13 +2894,27 @@ class TerminalController { return .err(code: "unavailable", message: "TabManager not available", data: nil) } + let cwd: String? + if let raw = params["cwd"] { + guard let str = raw as? String else { + return .err(code: "invalid_params", message: "cwd must be a string", data: nil) + } + cwd = str + } else { + cwd = nil + } + var newId: UUID? let shouldFocus = v2FocusAllowed() #if DEBUG let startedAt = ProcessInfo.processInfo.systemUptime #endif v2MainSync { - let ws = tabManager.addWorkspace(select: shouldFocus) + let ws = tabManager.addWorkspace( + workingDirectory: cwd, + select: shouldFocus, + eagerLoadTerminal: !shouldFocus + ) newId = ws.id } #if DEBUG @@ -2686,6 +3669,9 @@ class TerminalController { "index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]), "selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id]) ] + if let browserPanel = panel as? BrowserPanel { + item["developer_tools_visible"] = browserPanel.isDeveloperToolsVisible() + } return item } @@ -3191,7 +4177,7 @@ class TerminalController { var refreshedCount = 0 for panel in ws.panels.values { if let terminalPanel = panel as? TerminalPanel { - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceRefresh") refreshedCount += 1 } } @@ -3273,7 +4259,7 @@ class TerminalController { // Ensure we present a new frame after injecting input so snapshot-based tests (and // socket-driven agents) can observe the updated terminal without requiring a focus // change to trigger a draw. - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText") queued = false } else { // Avoid blocking the main actor waiting for view/surface attachment. @@ -3331,7 +4317,7 @@ class TerminalController { result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) return } - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result @@ -3363,7 +4349,7 @@ class TerminalController { return } - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceClearHistory") let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "workspace_id": ws.id.uuidString, @@ -3486,6 +4472,154 @@ class TerminalController { return "OK \(base64)" } + private struct PasteboardItemSnapshot { + let representations: [(type: NSPasteboard.PasteboardType, data: Data)] + } + + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), + url.isFileURL, + !url.path.isEmpty { + return url.path + } + return trimmed.hasPrefix("/") ? trimmed : nil + } + + nonisolated static func shouldRemoveExportedScreenFile( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let standardizedFile = fileURL.standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return standardizedFile.path.hasPrefix(temporary.path + "/") + } + + nonisolated static func shouldRemoveExportedScreenDirectory( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let directory = fileURL.deletingLastPathComponent().standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return directory.path.hasPrefix(temporary.path + "/") + } + + private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { + guard let items = pasteboard.pasteboardItems else { return [] } + return items.map { item in + let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in + guard let data = item.data(forType: type) else { return nil } + return (type: type, data: data) + } + return PasteboardItemSnapshot(representations: representations) + } + } + + private func restorePasteboardItems( + _ snapshots: [PasteboardItemSnapshot], + to pasteboard: NSPasteboard + ) { + _ = pasteboard.clearContents() + guard !snapshots.isEmpty else { return } + + let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in + guard !snapshot.representations.isEmpty else { return nil } + let item = NSPasteboardItem() + for representation in snapshot.representations { + item.setData(representation.data, forType: representation.type) + } + return item + } + guard !restoredItems.isEmpty else { return } + _ = pasteboard.writeObjects(restoredItems) + } + + private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? { + if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], + let firstURL = urls.first, + firstURL.isFileURL { + return firstURL.path + } + if let value = pasteboard.string(forType: .string) { + return value + } + return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text")) + } + + private func readTerminalTextFromVTExportForSnapshot( + terminalPanel: TerminalPanel, + lineLimit: Int? + ) -> String? { + // read_text strips style state; VT export keeps ANSI escape sequences. + let pasteboard = NSPasteboard.general + let snapshot = snapshotPasteboardItems(pasteboard) + defer { + restorePasteboardItems(snapshot, to: pasteboard) + } + + let initialChangeCount = pasteboard.changeCount + guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else { + return nil + } + guard pasteboard.changeCount != initialChangeCount else { + return nil + } + guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else { + return nil + } + + let fileURL = URL(fileURLWithPath: exportedPath) + defer { + if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) { + try? FileManager.default.removeItem(at: fileURL) + if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) { + try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) + } + } + } + + guard let data = try? Data(contentsOf: fileURL), + var output = String(data: data, encoding: .utf8) else { + return nil + } + if let lineLimit { + output = tailTerminalLines(output, maxLines: lineLimit) + } + return output + } + + func readTerminalTextForSnapshot( + terminalPanel: TerminalPanel, + includeScrollback: Bool = false, + lineLimit: Int? = nil + ) -> String? { + if includeScrollback, + let vtOutput = readTerminalTextFromVTExportForSnapshot( + terminalPanel: terminalPanel, + lineLimit: lineLimit + ) { + return vtOutput + } + + let response = readTerminalTextBase64( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + guard response.hasPrefix("OK ") else { return nil } + let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + if base64.isEmpty { + return "" + } + guard let data = Data(base64Encoded: base64), + let decoded = String(data: data, encoding: .utf8) else { + return nil + } + return decoded + } + private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -4261,6 +5395,109 @@ class TerminalController { return .ok([:]) } + private func v2FeedbackOpen(params: [String: Any]) -> V2CallResult { + let workspaceId = v2UUID(params, "workspace_id") + let windowId = v2UUID(params, "window_id") + let shouldActivate = v2Bool(params, "activate") ?? false + DispatchQueue.main.async { + let targetWindow: NSWindow? + if let windowId, let app = AppDelegate.shared { + targetWindow = app.mainWindow(for: windowId) + } else if let workspaceId, let app = AppDelegate.shared { + targetWindow = app.mainWindowContainingWorkspace(workspaceId) + } else { + targetWindow = nil + } + + if shouldActivate { + if let targetWindow { + targetWindow.makeKeyAndOrderFront(nil) + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } else { + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + } + + FeedbackComposerBridge.openComposer(in: targetWindow) + } + return .ok(["opened": true]) + } + + private func v2SettingsOpen(params: [String: Any]) -> V2CallResult { + let targetRaw = v2String(params, "target") + let shouldActivate = v2Bool(params, "activate") ?? true + + let navigationTarget: SettingsNavigationTarget? + switch targetRaw { + case nil: + navigationTarget = nil + case SettingsNavigationTarget.keyboardShortcuts.rawValue: + navigationTarget = .keyboardShortcuts + default: + return .err(code: "invalid_params", message: "Unknown settings target", data: ["target": targetRaw ?? ""]) + } + + DispatchQueue.main.async { + if shouldActivate { + AppDelegate.presentPreferencesWindow(navigationTarget: navigationTarget) + } else { + SettingsWindowController.shared.show(navigationTarget: navigationTarget) + } + } + return .ok([ + "opened": true, + "target": navigationTarget?.rawValue ?? "general", + ]) + } + + private func v2FeedbackSubmit(params: [String: Any]) -> V2CallResult { + guard let email = params["email"] as? String else { + return .err(code: "invalid_params", message: "Missing email", data: ["field": "email"]) + } + guard let body = params["body"] as? String else { + return .err(code: "invalid_params", message: "Missing body", data: ["field": "body"]) + } + let imagePaths = params["image_paths"] as? [String] ?? [] + + let semaphore = DispatchSemaphore(value: 0) + var result: V2CallResult = .err(code: "internal_error", message: "Feedback submission failed", data: nil) + + Task { + let resolved: V2CallResult + do { + let attachmentCount = try await FeedbackComposerBridge.submit( + email: email, + message: body, + imagePaths: imagePaths + ) + resolved = .ok([ + "submitted": true, + "attachment_count": attachmentCount, + ]) + } catch let error as FeedbackComposerBridgeError { + let code: String + switch error { + case .invalidEmail, .emptyMessage, .messageTooLong, .tooManyImages, .invalidImagePath: + code = "invalid_params" + case .submissionFailed: + code = "request_failed" + } + resolved = .err(code: code, message: error.localizedDescription, data: nil) + } catch { + resolved = .err(code: "internal_error", message: error.localizedDescription, data: nil) + } + + result = resolved + semaphore.signal() + } + + if semaphore.wait(timeout: .now() + 35) == .timedOut { + return .err(code: "timeout", message: "Feedback submission timed out", data: nil) + } + + return result + } + // MARK: - V2 App Focus Methods private func v2AppFocusOverride(params: [String: Any]) -> V2CallResult { @@ -4345,6 +5582,12 @@ class TerminalController { private func v2NormalizeJSValue(_ value: Any?) -> Any { guard let value else { return NSNull() } + if value is V2BrowserUndefinedSentinel { + return [ + Self.v2BrowserEvalEnvelopeTypeKey: Self.v2BrowserEvalEnvelopeTypeUndefined, + Self.v2BrowserEvalEnvelopeValueKey: NSNull() + ] + } if value is NSNull { return NSNull() } if let v = value as? String { return v } if let v = value as? NSNumber { return v } @@ -4367,28 +5610,74 @@ class TerminalController { case failure(String) } - private func v2RunJavaScript(_ webView: WKWebView, script: String, timeout: TimeInterval = 5.0) -> V2JavaScriptResult { + private func v2RunJavaScript( + _ webView: WKWebView, + script: String, + timeout: TimeInterval = 5.0, + preferAsync: Bool = false, + contentWorld: WKContentWorld + ) -> V2JavaScriptResult { + let timeoutSeconds = max(0.01, timeout) + let resultLock = NSLock() + let completionSignal = DispatchSemaphore(value: 0) var done = false var resultValue: Any? var resultError: String? - webView.evaluateJavaScript(script) { value, error in - if let error { - resultError = error.localizedDescription - } else { + let finish: (_ value: Any?, _ error: String?) -> Void = { value, error in + resultLock.lock() + if !done { + done = true resultValue = value + resultError = error + completionSignal.signal() } - done = true + resultLock.unlock() } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + let evaluator = { + if preferAsync, #available(macOS 11.0, *) { + webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in + switch result { + case .success(let value): + finish(value, nil) + case .failure(let error): + finish(nil, error.localizedDescription) + } + } + } else { + webView.evaluateJavaScript(script) { value, error in + if let error { + finish(nil, error.localizedDescription) + } else { + finish(value, nil) + } + } + } } - if !done { - return .failure("Timed out waiting for JavaScript result") + if Thread.isMainThread { + evaluator() + let deadline = Date().addingTimeInterval(timeoutSeconds) + while true { + resultLock.lock() + let isDone = done + resultLock.unlock() + if isDone { + break + } + if Date() >= deadline { + return .failure("Timed out waiting for JavaScript result") + } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + } else { + DispatchQueue.main.async(execute: evaluator) + if completionSignal.wait(timeout: .now() + timeoutSeconds) == .timedOut { + return .failure("Timed out waiting for JavaScript result") + } } + if let resultError { return .failure(resultError) } @@ -4438,32 +5727,110 @@ class TerminalController { _ webView: WKWebView, surfaceId: UUID, script: String, - timeout: TimeInterval = 5.0 + timeout: TimeInterval = 5.0, + useEval: Bool = true ) -> V2JavaScriptResult { - guard let frameSelector = v2BrowserCurrentFrameSelector(surfaceId: surfaceId) else { - return v2RunJavaScript(webView, script: script, timeout: timeout) + let scriptLiteral = v2JSONLiteral(script) + let framePrelude: String + if let frameSelector = v2BrowserCurrentFrameSelector(surfaceId: surfaceId) { + let selectorLiteral = v2JSONLiteral(frameSelector) + framePrelude = """ + let __cmuxDoc = document; + try { + const __cmuxFrame = document.querySelector(\(selectorLiteral)); + if (__cmuxFrame && __cmuxFrame.contentDocument) { + __cmuxDoc = __cmuxFrame.contentDocument; + } + } catch (_) {} + """ + } else { + framePrelude = "const __cmuxDoc = document;" } - let selectorLiteral = v2JSONLiteral(frameSelector) - let scriptLiteral = v2JSONLiteral(script) - let wrapped = """ - (() => { - let __cmuxDoc = document; - try { - const __cmuxFrame = document.querySelector(\(selectorLiteral)); - if (__cmuxFrame && __cmuxFrame.contentDocument) { - __cmuxDoc = __cmuxFrame.contentDocument; - } - } catch (_) {} + let executionBlock: String + if useEval { + executionBlock = "const __r = eval(\(scriptLiteral));" + } else { + executionBlock = "const __r = \(script);" + } - const __cmuxEvalInFrame = function() { - const document = __cmuxDoc; - return eval(\(scriptLiteral)); + let asyncFunctionBody = """ + \(framePrelude) + + const __cmuxMaybeAwait = async (__r) => { + if (__r !== null && (typeof __r === 'object' || typeof __r === 'function') && typeof __r.then === 'function') { + return await __r; + } + return __r; + }; + + const __cmuxEvalInFrame = async function() { + const document = __cmuxDoc; + \(executionBlock) + const __value = await __cmuxMaybeAwait(__r); + return { + __cmux_t: (typeof __value === 'undefined') ? 'undefined' : 'value', + __cmux_v: __value }; - return __cmuxEvalInFrame(); - })() + }; + + return await __cmuxEvalInFrame(); """ - return v2RunJavaScript(webView, script: wrapped, timeout: timeout) + + var rawResult: V2JavaScriptResult + if #available(macOS 11.0, *) { + rawResult = v2RunJavaScript( + webView, + script: asyncFunctionBody, + timeout: timeout, + preferAsync: true, + contentWorld: .page + ) + } else { + let evaluateFallback = """ + (async () => { + \(asyncFunctionBody) + })() + """ + rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout, contentWorld: .page) + } + + if !useEval, case .failure(let pageMessage) = rawResult, #available(macOS 11.0, *) { + let isolatedResult = v2RunJavaScript( + webView, + script: asyncFunctionBody, + timeout: timeout, + preferAsync: true, + contentWorld: .defaultClient + ) + switch isolatedResult { + case .success: + rawResult = isolatedResult + case .failure(let isolatedMessage): + if isolatedMessage != pageMessage { + rawResult = .failure("\(pageMessage) (isolated-world retry: \(isolatedMessage))") + } + } + } + + switch rawResult { + case .failure(let message): + return .failure(message) + case .success(let value): + guard let dict = value as? [String: Any], + let type = dict[Self.v2BrowserEvalEnvelopeTypeKey] as? String else { + return .success(value) + } + + switch type { + case Self.v2BrowserEvalEnvelopeTypeUndefined: + return .success(v2BrowserUndefinedSentinel) + case Self.v2BrowserEvalEnvelopeTypeValue: + return .success(dict[Self.v2BrowserEvalEnvelopeValueKey]) + default: + return .success(value) + } + } } private func v2BrowserRecordUnsupportedRequest(surfaceId: UUID, request: [String: Any]) { @@ -4544,44 +5911,137 @@ class TerminalController { } } - private func v2BrowserWaitForCondition( - _ conditionScript: String, - webView: WKWebView, - surfaceId: UUID? = nil, - timeout: TimeInterval = 5.0, - pollInterval: TimeInterval = 0.05 - ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let wrapped = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()" - let jsResult: V2JavaScriptResult - if let surfaceId { - jsResult = v2RunBrowserJavaScript(webView, surfaceId: surfaceId, script: wrapped, timeout: max(0.5, pollInterval + 0.25)) - } else { - jsResult = v2RunJavaScript(webView, script: wrapped, timeout: max(0.5, pollInterval + 0.25)) - } - if case let .success(value) = jsResult, - let ok = value as? Bool, - ok { - return true - } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(pollInterval)) - } - return false - } - private func v2PNGData(from image: NSImage) -> Data? { guard let tiff = image.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { return nil } return rep.representation(using: .png, properties: [:]) } + private func bestEffortPruneTemporaryFiles( + in directoryURL: URL, + keepingMostRecent maxCount: Int = 50, + maxAge: TimeInterval = 24 * 60 * 60 + ) { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + let now = Date() + let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]), + values.isRegularFile == true else { + return nil + } + return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast) + }.sorted { $0.date > $1.date } + + for (index, entry) in datedEntries.enumerated() { + if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge { + try? FileManager.default.removeItem(at: entry.url) + } + } + } + + // MARK: - Markdown + + private func v2MarkdownOpen(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let rawPath = v2String(params, "path") else { + return .err(code: "invalid_params", message: "Missing 'path' parameter", data: nil) + } + + // Resolve the path (expand ~ and standardize) + let expandedPath = NSString(string: rawPath).expandingTildeInPath + let filePath = NSString(string: expandedPath).standardizingPath + + // Reject paths that aren't absolute after resolution + guard filePath.hasPrefix("/") else { + return .err(code: "invalid_params", message: "Path must be absolute: \(filePath)", data: ["path": filePath]) + } + + // Validate the file exists and is a regular file (not a directory) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: filePath, isDirectory: &isDir) else { + return .err(code: "not_found", message: "File not found: \(filePath)", data: ["path": filePath]) + } + guard !isDir.boolValue else { + return .err(code: "invalid_params", message: "Path is a directory, not a file: \(filePath)", data: ["path": filePath]) + } + guard FileManager.default.isReadableFile(atPath: filePath) else { + return .err(code: "permission_denied", message: "File not readable: \(filePath)", data: ["path": filePath]) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to create markdown panel", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + + let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let sourceSurfaceId else { + result = .err(code: "not_found", message: "No focused surface to split", data: nil) + return + } + guard ws.panels[sourceSurfaceId] != nil else { + result = .err(code: "not_found", message: "Source surface not found", data: ["surface_id": sourceSurfaceId.uuidString]) + return + } + + let sourcePaneUUID = ws.paneId(forPanelId: sourceSurfaceId)?.id + + let createdPanel = ws.newMarkdownSplit( + from: sourceSurfaceId, + orientation: .horizontal, + filePath: filePath, + focus: v2FocusAllowed() + ) + + guard let markdownPanelId = createdPanel?.id else { + result = .err(code: "internal_error", message: "Failed to create markdown panel", data: nil) + return + } + + let targetPaneUUID = ws.paneId(forPanelId: markdownPanelId)?.id + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": v2OrNull(targetPaneUUID?.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID), + "surface_id": markdownPanelId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: markdownPanelId), + "source_surface_id": sourceSurfaceId.uuidString, + "source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId), + "source_pane_id": v2OrNull(sourcePaneUUID?.uuidString), + "source_pane_ref": v2Ref(kind: .pane, uuid: sourcePaneUUID), + "target_pane_id": v2OrNull(targetPaneUUID?.uuidString), + "target_pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID), + "path": filePath + ]) + } + return result + } + + // MARK: - Browser + private func v2BrowserOpenSplit(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } let urlStr = v2String(params, "url") let url = urlStr.flatMap { URL(string: $0) } + let respectExternalOpenRules = v2Bool(params, "respect_external_open_rules") ?? false var result: V2CallResult = .err(code: "internal_error", message: "Failed to create browser", data: nil) v2MainSync { @@ -4589,6 +6049,34 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } + if let url, + respectExternalOpenRules, + BrowserLinkOpenSettings.shouldOpenExternally(url) { + guard NSWorkspace.shared.open(url) else { + result = .err( + code: "external_open_failed", + message: "Failed to open URL externally", + data: ["url": url.absoluteString] + ) + return + } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": v2OrNull(nil), + "pane_ref": v2Ref(kind: .pane, uuid: nil), + "surface_id": v2OrNull(nil), + "surface_ref": v2Ref(kind: .surface, uuid: nil), + "created_split": false, + "placement_strategy": "external", + "opened_externally": true, + "url": url.absoluteString + ]) + return + } v2MaybeFocusWindow(for: tabManager) v2MaybeSelectWorkspace(tabManager, workspace: ws) @@ -4878,7 +6366,7 @@ class TerminalController { let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3) for attempt in 1...retryAttempts { - switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, useEval: false) { case .failure(let message): return .err(code: "js_error", message: message, data: ["action": actionName, "selector": selector]) case .success(let value): @@ -5136,7 +6624,7 @@ class TerminalController { })() """ - switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) { + switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0, useEval: false) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -5233,42 +6721,120 @@ class TerminalController { private func v2BrowserWait(params: [String: Any]) -> V2CallResult { let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000) let timeout = Double(timeoutMs) / 1000.0 + let selectorRaw = v2BrowserSelector(params) - return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in - let conditionScript: String = { - if let selector = v2BrowserSelector(params) { - let literal = v2JSONLiteral(selector) - return "document.querySelector(\(literal)) !== null" + let conditionScriptBase: String = { + if let urlContains = v2String(params, "url_contains") { + let literal = v2JSONLiteral(urlContains) + return "String(location.href || '').includes(\(literal))" + } + if let textContains = v2String(params, "text_contains") { + let literal = v2JSONLiteral(textContains) + return "(document.body && String(document.body.innerText || '').includes(\(literal)))" + } + if let loadState = v2String(params, "load_state") { + let normalizedLoadState = loadState.lowercased() + if normalizedLoadState == "interactive" { + return """ + (() => { + const __state = String(document.readyState || '').toLowerCase(); + return __state === 'interactive' || __state === 'complete'; + })() + """ } - if let urlContains = v2String(params, "url_contains") { - let literal = v2JSONLiteral(urlContains) - return "String(location.href || '').includes(\(literal))" - } - if let textContains = v2String(params, "text_contains") { - let literal = v2JSONLiteral(textContains) - return "(document.body && String(document.body.innerText || '').includes(\(literal)))" - } - if let loadState = v2String(params, "load_state") { - let literal = v2JSONLiteral(loadState.lowercased()) - return "String(document.readyState || '').toLowerCase() === \(literal)" - } - if let fn = v2String(params, "function") { - return "(() => { return !!(\(fn)); })()" - } - return "document.readyState === 'complete'" - }() + let literal = v2JSONLiteral(normalizedLoadState) + return "String(document.readyState || '').toLowerCase() === \(literal)" + } + if let fn = v2String(params, "function") { + return "(() => { return !!(\(fn)); })()" + } + return "document.readyState === 'complete'" + }() - let ok = v2BrowserWaitForCondition(conditionScript, webView: browserPanel.webView, surfaceId: surfaceId, timeout: timeout) - if !ok { + var setupResult: V2CallResult? + var workspaceId: UUID? + var surfaceIdOut: UUID? + var webView: WKWebView? + + v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + setupResult = .err(code: "unavailable", message: "TabManager not available", data: nil) + return + } + guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + setupResult = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let surfaceId = self.v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let surfaceId else { + setupResult = .err(code: "not_found", message: "No focused browser surface", data: nil) + return + } + guard let browserPanel = ws.browserPanel(for: surfaceId) else { + setupResult = .err(code: "invalid_params", message: "Surface is not a browser", data: ["surface_id": surfaceId.uuidString]) + return + } + workspaceId = ws.id + surfaceIdOut = surfaceId + webView = browserPanel.webView + } + + if let setupResult { + return setupResult + } + guard let workspaceId, let surfaceIdOut, let webView else { + return .err(code: "internal_error", message: "Failed to resolve browser surface", data: nil) + } + + let conditionScript: String + if let selectorRaw { + guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceIdOut) else { + return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw]) + } + let literal = v2JSONLiteral(selector) + conditionScript = "document.querySelector(\(literal)) !== null" + } else { + conditionScript = conditionScriptBase + } + + let deadline = Date().addingTimeInterval(timeout) + let pollInterval = 0.05 + let wrappedScript = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()" + + while true { + switch v2RunBrowserJavaScript( + webView, + surfaceId: surfaceIdOut, + script: wrappedScript, + timeout: max(0.5, pollInterval + 0.25), + useEval: false + ) { + case .success(let value): + if let b = value as? Bool, b { + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceIdOut.uuidString, + "surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut), + "waited": true + ]) + } + case .failure(let message): + return .err( + code: "js_error", + message: message, + data: [ + "condition": conditionScript, + "timeout_ms": timeoutMs + ] + ) + } + + if Date() >= deadline { return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs]) } - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "waited": true - ]) + + Thread.sleep(forTimeInterval: pollInterval) } } @@ -5613,13 +7179,31 @@ class TerminalController { return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil) } - return .ok([ + var result: [String: Any] = [ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "png_base64": imageData.base64EncodedString() - ]) + ] + + // Best effort: keep screenshot data available even when temp-file writes fail. + let screenshotsDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-browser-screenshots", isDirectory: true) + if (try? FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true)) != nil { + bestEffortPruneTemporaryFiles(in: screenshotsDirectory) + let timestampMs = Int(Date().timeIntervalSince1970 * 1000) + let shortSurfaceId = String(surfaceId.uuidString.prefix(8)) + let shortRandomId = String(UUID().uuidString.prefix(8)) + let filename = "surface-\(shortSurfaceId)-\(timestampMs)-\(shortRandomId).png" + let imageURL = screenshotsDirectory.appendingPathComponent(filename, isDirectory: false) + if (try? imageData.write(to: imageURL, options: .atomic)) != nil { + result["path"] = imageURL.path + result["url"] = imageURL.absoluteString + } + } + + return .ok(result) } } @@ -6445,99 +8029,28 @@ class TerminalController { } } - private func v2BrowserEnsureTelemetryHooks(surfaceId: UUID, browserPanel: BrowserPanel) { - let script = """ - (() => { - if (window.__cmuxHooksInstalled) return true; - window.__cmuxHooksInstalled = true; + private func v2BrowserEnsureTelemetryHooks(surfaceId _: UUID, browserPanel: BrowserPanel) { + _ = v2RunJavaScript( + browserPanel.webView, + script: BrowserPanel.telemetryHookBootstrapScriptSource, + timeout: 5.0, + contentWorld: .page + ) + } - window.__cmuxConsoleLog = window.__cmuxConsoleLog || []; - const __pushConsole = (level, args) => { - try { - const text = Array.from(args || []).map((x) => { - if (typeof x === 'string') return x; - try { return JSON.stringify(x); } catch (_) { return String(x); } - }).join(' '); - window.__cmuxConsoleLog.push({ level, text, timestamp_ms: Date.now() }); - if (window.__cmuxConsoleLog.length > 512) { - window.__cmuxConsoleLog.splice(0, window.__cmuxConsoleLog.length - 512); - } - } catch (_) {} - }; - - const methods = ['log', 'info', 'warn', 'error', 'debug']; - for (const m of methods) { - const orig = (window.console && window.console[m]) ? window.console[m].bind(window.console) : null; - window.console[m] = function(...args) { - __pushConsole(m, args); - if (orig) return orig(...args); - }; - } - - window.__cmuxErrorLog = window.__cmuxErrorLog || []; - window.addEventListener('error', (ev) => { - try { - const message = String((ev && ev.message) || ''); - const source = String((ev && ev.filename) || ''); - const line = Number((ev && ev.lineno) || 0); - const col = Number((ev && ev.colno) || 0); - window.__cmuxErrorLog.push({ message, source, line, column: col, timestamp_ms: Date.now() }); - if (window.__cmuxErrorLog.length > 512) { - window.__cmuxErrorLog.splice(0, window.__cmuxErrorLog.length - 512); - } - } catch (_) {} - }); - window.addEventListener('unhandledrejection', (ev) => { - try { - const reason = ev && ev.reason; - const message = typeof reason === 'string' ? reason : (reason && reason.message ? String(reason.message) : String(reason)); - window.__cmuxErrorLog.push({ message, source: 'unhandledrejection', line: 0, column: 0, timestamp_ms: Date.now() }); - if (window.__cmuxErrorLog.length > 512) { - window.__cmuxErrorLog.splice(0, window.__cmuxErrorLog.length - 512); - } - } catch (_) {} - }); - - window.__cmuxDialogQueue = window.__cmuxDialogQueue || []; - window.__cmuxDialogDefaults = window.__cmuxDialogDefaults || { confirm: false, prompt: null }; - const __pushDialog = (type, message, defaultText) => { - window.__cmuxDialogQueue.push({ - type, - message: String(message || ''), - default_text: defaultText == null ? null : String(defaultText), - timestamp_ms: Date.now() - }); - if (window.__cmuxDialogQueue.length > 128) { - window.__cmuxDialogQueue.splice(0, window.__cmuxDialogQueue.length - 128); - } - }; - - window.alert = function(message) { - __pushDialog('alert', message, null); - }; - window.confirm = function(message) { - __pushDialog('confirm', message, null); - return !!window.__cmuxDialogDefaults.confirm; - }; - window.prompt = function(message, defaultValue) { - __pushDialog('prompt', message, defaultValue == null ? null : defaultValue); - const v = window.__cmuxDialogDefaults.prompt; - if (v === null || v === undefined) { - return defaultValue == null ? '' : String(defaultValue); - } - return String(v); - }; - - return true; - })() - """ - - _ = v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) + private func v2BrowserEnsureDialogHooks(browserPanel: BrowserPanel) { + _ = v2RunJavaScript( + browserPanel.webView, + script: BrowserPanel.dialogTelemetryHookBootstrapScriptSource, + timeout: 5.0, + contentWorld: .page + ) } private func v2BrowserDialogRespond(params: [String: Any], accept: Bool) -> V2CallResult { return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in v2BrowserEnsureTelemetryHooks(surfaceId: surfaceId, browserPanel: browserPanel) + v2BrowserEnsureDialogHooks(browserPanel: browserPanel) let text = v2String(params, "text") ?? v2String(params, "prompt_text") let acceptLiteral = accept ? "true" : "false" let textLiteral = text.map(v2JSONLiteral) ?? "null" @@ -6562,7 +8075,7 @@ class TerminalController { })() """ - switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) { + switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -7157,7 +8670,7 @@ class TerminalController { return { ok: true, items }; })() """ - switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) { + switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -7195,7 +8708,7 @@ class TerminalController { return { ok: true, items }; })() """ - switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) { + switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -7564,6 +9077,294 @@ class TerminalController { return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } + private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow) + } + return result + } + + private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + + private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + var selectedIndex = 0 + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex) + ]) + } + + private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let requestedLimit = params["limit"] as? Int + let limit = max(1, min(100, requestedLimit ?? 20)) + + var visible = false + var selectedIndex = 0 + var snapshot = CommandPaletteDebugSnapshot.empty + + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty + } + + let rows = Array(snapshot.results.prefix(limit)).map { row in + [ + "command_id": row.commandId, + "title": row.title, + "shortcut_hint": v2OrNull(row.shortcutHint), + "trailing_label": v2OrNull(row.trailingLabel), + "score": row.score + ] as [String: Any] + } + + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex), + "query": snapshot.query, + "mode": snapshot.mode, + "results": rows + ]) + } + + private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + + var result: V2CallResult = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": false, + "selection_location": 0, + "selection_length": 0, + "text_length": 0 + ]) + + DispatchQueue.main.sync { + guard let window = AppDelegate.shared?.mainWindow(for: windowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + return + } + guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { + return + } + let selectedRange = editor.selectedRange() + let textLength = (editor.string as NSString).length + result = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": true, + "selection_location": max(0, selectedRange.location), + "selection_length": max(0, selectedRange.length), + "text_length": max(0, textLength) + ]) + } + + return result + } + + private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult { + if let rawEnabled = params["enabled"] { + guard let enabled = rawEnabled as? Bool else { + return .err( + code: "invalid_params", + message: "enabled must be a bool", + data: ["enabled": rawEnabled] + ) + } + DispatchQueue.main.sync { + UserDefaults.standard.set( + enabled, + forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey + ) + } + } + + var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + DispatchQueue.main.sync { + enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() + } + + return .ok([ + "enabled": enabled + ]) + } + + private func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult { + let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id") + var focusedSurfaceId: UUID? + DispatchQueue.main.sync { + focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId() + } + + var payload: [String: Any] = [ + "focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString), + "focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), + "focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString), + "focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), + "focused": focusedSurfaceId != nil + ] + + if let requestedSurfaceId { + payload["surface_id"] = requestedSurfaceId.uuidString + payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) + payload["panel_id"] = requestedSurfaceId.uuidString + payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) + payload["focused"] = (focusedSurfaceId == requestedSurfaceId) + } + + return .ok(payload) + } + + private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visibility: Bool? + DispatchQueue.main.sync { + visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId) + } + guard let visible = visibility else { + return .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult { guard let surfaceId = v2String(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing surface_id", data: nil) @@ -7612,6 +9413,13 @@ class TerminalController { return .ok(["layout": obj]) } + private func v2DebugPortalStats() -> V2CallResult { + let payload: [String: Any] = v2MainSync { + TerminalWindowPortalRegistry.debugPortalStats() + } + return .ok(payload) + } + private func v2DebugBonsplitUnderflowCount() -> V2CallResult { let resp = bonsplitUnderflowCount() guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) } @@ -7866,12 +9674,18 @@ class TerminalController { notify_surface <id|idx> <payload> - Notify a specific surface notify_target <workspace_id> <surface_id> <payload> - Notify by workspace+surface list_notifications - List all notifications - clear_notifications - Clear all notifications + clear_notifications [--tab=X] - Clear notifications (all or per-tab) set_app_focus <active|inactive|clear> - Override app focus state simulate_app_active - Trigger app active handler - set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X] - Set a status entry + set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry + report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry + report_meta_block <key> [--priority=N] [--tab=X] -- <markdown> - Set freeform sidebar markdown block clear_status <key> [--tab=X] - Remove a status entry + clear_meta <key> [--tab=X] - Remove sidebar metadata entry + clear_meta_block <key> [--tab=X] - Remove sidebar markdown block list_status [--tab=X] - List all status entries + list_meta [--tab=X] - List sidebar metadata entries + list_meta_blocks [--tab=X] - List sidebar markdown blocks log [--level=X] [--source=X] [--tab=X] -- <message> - Append a log entry clear_log [--tab=X] - Clear log entries list_log [--limit=N] [--tab=X] - List log entries @@ -7879,6 +9693,9 @@ class TerminalController { clear_progress [--tab=X] - Clear progress bar report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch clear_git_branch [--tab=X] [--panel=Y] - Clear git branch + report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item + report_review <number> <url> [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item + clear_pr [--tab=X] [--panel=Y] - Clear pull request report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel @@ -7923,6 +9740,7 @@ class TerminalController { sidebar_overlay_gate [active|inactive] - Return true/false if sidebar outside-drop overlay would capture (test-only) terminal_drop_overlay_probe [deferred|direct] - Trigger focused terminal drop-overlay show path and report animation counts (test-only) activate_app - Bring app + main window to front (test-only) + send_workspace <workspace_id> <text> - Send text to a workspace's selected terminal (test-only) is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only) read_terminal_text [id|idx] - Read visible terminal text (base64, test-only) render_stats [id|idx] - Read terminal render stats (draw counters, test-only) @@ -7937,6 +9755,37 @@ class TerminalController { } #if DEBUG + private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String { + let snakeCase = action.rawValue.replacingOccurrences( + of: "([a-z0-9])([A-Z])", + with: "$1_$2", + options: .regularExpression + ) + return snakeCase.lowercased() + } + + private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? { + let normalized = rawName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "-", with: "_") + + for action in KeyboardShortcutSettings.Action.allCases { + let snakeCaseName = debugShortcutName(for: action) + if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") { + return action + } + } + return nil + } + + private func debugShortcutSupportedNames() -> String { + KeyboardShortcutSettings.Action.allCases + .map(debugShortcutName(for:)) + .sorted() + .joined(separator: ", ") + } + private func setShortcut(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) @@ -7944,29 +9793,15 @@ class TerminalController { return "ERROR: Usage: set_shortcut <name> <combo|clear>" } - let name = parts[0].lowercased() + let name = parts[0] let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - let defaultsKey: String? - switch name { - case "focus_left", "focusleft": - defaultsKey = KeyboardShortcutSettings.focusLeftKey - case "focus_right", "focusright": - defaultsKey = KeyboardShortcutSettings.focusRightKey - case "focus_up", "focusup": - defaultsKey = KeyboardShortcutSettings.focusUpKey - case "focus_down", "focusdown": - defaultsKey = KeyboardShortcutSettings.focusDownKey - default: - defaultsKey = nil - } - - guard let defaultsKey else { - return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down" + guard let action = debugShortcutAction(named: name) else { + return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())" } if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { - UserDefaults.standard.removeObject(forKey: defaultsKey) + UserDefaults.standard.removeObject(forKey: action.defaultsKey) return "OK" } @@ -7984,78 +9819,95 @@ class TerminalController { guard let data = try? JSONEncoder().encode(shortcut) else { return "ERROR: Failed to encode shortcut" } - UserDefaults.standard.set(data, forKey: defaultsKey) + UserDefaults.standard.set(data, forKey: action.defaultsKey) return "OK" } - private func simulateShortcut(_ args: String) -> String { - let combo = args.trimmingCharacters(in: .whitespacesAndNewlines) - guard !combo.isEmpty else { - return "ERROR: Usage: simulate_shortcut <combo>" - } - guard let parsed = parseShortcutCombo(combo) else { - return "ERROR: Invalid combo. Example: cmd+ctrl+h" - } + private func prepareWindowForSyntheticInput(_ window: NSWindow?) { + guard let window else { return } - // Stamp at socket-handler arrival so event.timestamp includes any wait - // before the main-thread event dispatch. - let requestTimestamp = ProcessInfo.processInfo.systemUptime - - var result = "ERROR: Failed to create event" - DispatchQueue.main.sync { - // Tests can run while the app is activating (no keyWindow yet). Prefer a visible - // window to keep input simulation deterministic in debug builds. - let targetWindow = NSApp.keyWindow - ?? NSApp.mainWindow - ?? NSApp.windows.first(where: { $0.isVisible }) - ?? NSApp.windows.first - if let targetWindow { - NSApp.activate(ignoringOtherApps: true) - targetWindow.makeKeyAndOrderFront(nil) - } - let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0 - guard let keyDownEvent = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: parsed.modifierFlags, - timestamp: requestTimestamp, - windowNumber: windowNumber, - context: nil, - characters: parsed.characters, - charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, - isARepeat: false, - keyCode: parsed.keyCode - ) else { - result = "ERROR: NSEvent.keyEvent returned nil" - return - } - let keyUpEvent = NSEvent.keyEvent( - with: .keyUp, - location: .zero, - modifierFlags: parsed.modifierFlags, - timestamp: requestTimestamp + 0.0001, - windowNumber: windowNumber, - context: nil, - characters: parsed.characters, - charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, - isARepeat: false, - keyCode: parsed.keyCode - ) - // Socket-driven shortcut simulation should reuse the exact same matching logic as the - // app-level shortcut monitor (so tests are hermetic), while still falling back to the - // normal responder chain for plain typing. - if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) { - result = "OK" - return - } - NSApp.sendEvent(keyDownEvent) - if let keyUpEvent { - NSApp.sendEvent(keyUpEvent) - } - result = "OK" - } - return result - } + // Keep socket-driven input simulation focused on the intended window without + // paying repeated activation/order-front costs for every synthetic key event. + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + if !window.isKeyWindow || !window.isVisible { + window.makeKeyAndOrderFront(nil) + } + } + + private func simulateShortcut(_ args: String) -> String { + let combo = args.trimmingCharacters(in: .whitespacesAndNewlines) + guard !combo.isEmpty else { + return "ERROR: Usage: simulate_shortcut <combo>" + } + guard let parsed = parseShortcutCombo(combo) else { + return "ERROR: Invalid combo. Example: cmd+ctrl+h" + } + + // Stamp at socket-handler arrival so event.timestamp includes any wait + // before the main-thread event dispatch. + let requestTimestamp = ProcessInfo.processInfo.systemUptime + + var result = "ERROR: Failed to create event" + DispatchQueue.main.sync { + // Prefer the current active-tab-manager window so shortcut simulation stays + // scoped to the intended window even when NSApp.keyWindow is stale. + let targetWindow: NSWindow? = { + if let activeTabManager = self.tabManager, + let windowId = AppDelegate.shared?.windowId(for: activeTabManager), + let window = AppDelegate.shared?.mainWindow(for: windowId) { + return window + } + return NSApp.keyWindow + ?? NSApp.mainWindow + ?? NSApp.windows.first(where: { $0.isVisible }) + ?? NSApp.windows.first + }() + prepareWindowForSyntheticInput(targetWindow) + let windowNumber = targetWindow?.windowNumber ?? 0 + guard let keyDownEvent = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: parsed.modifierFlags, + timestamp: requestTimestamp, + windowNumber: windowNumber, + context: nil, + characters: parsed.characters, + charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, + isARepeat: false, + keyCode: parsed.keyCode + ) else { + result = "ERROR: NSEvent.keyEvent returned nil" + return + } + let keyUpEvent = NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: parsed.modifierFlags, + timestamp: requestTimestamp + 0.0001, + windowNumber: windowNumber, + context: nil, + characters: parsed.characters, + charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, + isARepeat: false, + keyCode: parsed.keyCode + ) + // Socket-driven shortcut simulation should reuse the exact same matching logic as the + // app-level shortcut monitor (so tests are hermetic), while still falling back to the + // normal responder chain for plain typing. + if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) { + result = "OK" + return + } + NSApp.sendEvent(keyDownEvent) + if let keyUpEvent { + NSApp.sendEvent(keyUpEvent) + } + result = "OK" + } + return result + } private func activateApp() -> String { DispatchQueue.main.sync { @@ -8092,20 +9944,19 @@ class TerminalController { // Socket commands are line-based; allow callers to express control chars with backslash escapes. let text = unescapeSocketText(raw) - var result = "ERROR: No window" - DispatchQueue.main.sync { - // Like simulate_shortcut, prefer a visible window so debug automation doesn't - // fail during key window transitions. - guard let window = NSApp.keyWindow - ?? NSApp.mainWindow - ?? NSApp.windows.first(where: { $0.isVisible }) - ?? NSApp.windows.first else { return } - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - guard let fr = window.firstResponder else { - result = "ERROR: No first responder" - return - } + var result = "ERROR: No window" + DispatchQueue.main.sync { + // Like simulate_shortcut, prefer a visible window so debug automation doesn't + // fail during key window transitions. + guard let window = NSApp.keyWindow + ?? NSApp.mainWindow + ?? NSApp.windows.first(where: { $0.isVisible }) + ?? NSApp.windows.first else { return } + prepareWindowForSyntheticInput(window) + guard let fr = window.firstResponder else { + result = "ERROR: No first responder" + return + } if let client = fr as? NSTextInputClient { client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) @@ -8113,7 +9964,22 @@ class TerminalController { return } - // Fall back to the responder chain insertText action. + // If workspace handoff temporarily leaves a non-terminal first responder, + // route debug typing to the selected terminal's focused panel directly. + if let tabManager, + let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }), + let panelId = tab.focusedPanelId, + let terminalPanel = tab.terminalPanel(for: panelId), + !terminalPanel.hostedView.isSurfaceViewFirstResponder() { + // Match Enter semantics expected by tests/debug tooling when bypassing AppKit. + let directText = text.replacingOccurrences(of: "\n", with: "\r") + terminalPanel.surface.sendText(directText) + result = "OK" + return + } + + // Fall back to the responder-chain insertText action. (fr as? NSResponder)?.insertText(text) result = "OK" } @@ -8706,6 +10572,10 @@ class TerminalController { let charactersIgnoringModifiers: String switch keyToken.lowercased() { + case "esc", "escape": + storedKey = "\u{1b}" + keyCode = UInt16(kVK_Escape) + charactersIgnoringModifiers = storedKey case "left": storedKey = "←" keyCode = 123 @@ -8726,6 +10596,10 @@ class TerminalController { storedKey = "\r" keyCode = UInt16(kVK_Return) charactersIgnoringModifiers = storedKey + case "backspace", "delete", "del": + storedKey = "\u{7f}" + keyCode = UInt16(kVK_Delete) + charactersIgnoringModifiers = storedKey default: let key = keyToken.lowercased() guard let code = keyCodeForShortcutKey(key) else { return nil } @@ -8927,7 +10801,7 @@ class TerminalController { let startedAt = ProcessInfo.processInfo.systemUptime #endif DispatchQueue.main.sync { - let workspace = tabManager.addTab(select: focus) + let workspace = tabManager.addTab(select: focus, eagerLoadTerminal: !focus) newTabId = workspace.id } #if DEBUG @@ -9110,7 +10984,13 @@ class TerminalController { var result = "OK" DispatchQueue.main.sync { - guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else { + let tab: Tab? + if let tabId = UUID(uuidString: tabArg) { + tab = tabForSidebarMutation(id: tabId) + } else { + tab = resolveTab(from: tabArg, tabManager: tabManager) + } + guard let tab else { result = "ERROR: Tab not found" return } @@ -9144,9 +11024,30 @@ class TerminalController { return result.isEmpty ? "No notifications" : result } - private func clearNotifications() -> String { + private func clearNotifications(_ args: String) -> String { + let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + DispatchQueue.main.sync { + TerminalNotificationStore.shared.clearAll() + } + return "OK" + } + let parsed = parseOptions(trimmed) + guard let tabOption = parsed.options["tab"], + !tabOption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "ERROR: Usage: clear_notifications [--tab=X]" + } + var tabId: UUID? DispatchQueue.main.sync { - TerminalNotificationStore.shared.clearAll() + if let tab = resolveTabForReport(trimmed) { + tabId = tab.id + } + } + guard let tabId else { + return "ERROR: Tab not found" + } + DispatchQueue.main.sync { + TerminalNotificationStore.shared.clearNotifications(forTabId: tabId) } return "OK" } @@ -9373,7 +11274,7 @@ class TerminalController { var cgImage = view.debugCopyIOSurfaceCGImage() if cgImage == nil { // If the surface is mid-attach we may not have contents yet. Nudge a draw and retry once. - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.debugCopyIOSurfaceRetry") cgImage = view.debugCopyIOSurfaceCGImage() } guard let cgImage else { @@ -9836,8 +11737,8 @@ class TerminalController { private func parseNotificationPayload(_ args: String) -> (String, String, String) { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return ("Notification", "", "") } - let parts = trimmed.split(separator: "|", maxSplits: 2).map(String.init) - let title = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false).map(String.init) + let title = parts.count > 0 ? parts[0].trimmingCharacters(in: .whitespacesAndNewlines) : "" let subtitle = parts.count > 2 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" let body = parts.count > 2 ? parts[2].trimmingCharacters(in: .whitespacesAndNewlines) @@ -10104,6 +12005,97 @@ class TerminalController { return success ? "OK" : "ERROR: Failed to send input" } + private func sendInputToWorkspace(_ args: String) -> String { + guard let tabManager else { return "ERROR: TabManager not available" } + let parts = args.split(separator: " ", maxSplits: 1).map(String.init) + guard parts.count == 2 else { return "ERROR: Usage: send_workspace <workspace_id> <text>" } + + let workspaceArg = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + let text = parts[1] + guard let workspaceId = UUID(uuidString: workspaceArg) else { + return "ERROR: Invalid workspace ID" + } + + var success = false + var error: String? + DispatchQueue.main.sync { + guard let targetManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) + ?? (tabManager.tabs.contains(where: { $0.id == workspaceId }) ? tabManager : nil) else { + error = "ERROR: Workspace not found" + return + } + guard let tab = targetManager.tabs.first(where: { $0.id == workspaceId }) else { + error = "ERROR: Workspace not found" + return + } + + guard let terminalPanel = sendableWorkspaceTerminalPanel(in: tab) else { + error = "ERROR: No selected terminal in workspace" + return + } + + let unescaped = text + .replacingOccurrences(of: "\\n", with: "\r") + .replacingOccurrences(of: "\\r", with: "\r") + .replacingOccurrences(of: "\\t", with: "\t") + + // This DEBUG-only command is used by UI tests to enqueue shell work in an + // existing workspace. Return once the input is queued on main so a long + // payload does not hold the control-socket response open in CI. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if let surface = terminalPanel.surface.surface { + self.sendSocketText(unescaped, surface: surface) + } else { + terminalPanel.sendText(unescaped) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } + success = true + } + + if let error { return error } + return success ? "OK" : "ERROR: Failed to send input" + } + + private func sendableWorkspaceTerminalPanel(in workspace: Workspace) -> TerminalPanel? { + func selectedTerminalPanel(in paneId: PaneID) -> TerminalPanel? { + guard let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId), + let panelId = workspace.panelIdFromSurfaceId(selectedTab.id), + let terminalPanel = workspace.panels[panelId] as? TerminalPanel else { + return nil + } + return terminalPanel + } + + func isSelectedTerminalPanel(_ terminalPanel: TerminalPanel) -> Bool { + guard let surfaceId = workspace.surfaceIdFromPanelId(terminalPanel.id) else { + return false + } + return workspace.bonsplitController.allPaneIds.contains { paneId in + workspace.bonsplitController.selectedTab(inPane: paneId)?.id == surfaceId + } + } + + if let focusedPane = workspace.bonsplitController.focusedPaneId, + let terminalPanel = selectedTerminalPanel(in: focusedPane) { + return terminalPanel + } + + if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance(), + isSelectedTerminalPanel(rememberedTerminal) { + return rememberedTerminal + } + + for paneId in workspace.bonsplitController.allPaneIds { + if let terminalPanel = selectedTerminalPanel(in: paneId) { + return terminalPanel + } + } + + return nil + } + private func sendInputToSurface(_ args: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) @@ -10808,29 +12800,103 @@ class TerminalController { return tabManager.tabs.first(where: { $0.id == selectedId }) } - private func setStatus(_ args: String) -> String { + private func resolveTabIdForSidebarMutation( + reportArgs: String, + options: [String: String] + ) -> (tabId: UUID?, error: String?) { + var tabId: UUID? + DispatchQueue.main.sync { + if let tab = resolveTabForReport(reportArgs) { + tabId = tab.id + } + } + if let tabId { + return (tabId, nil) + } + let error = options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return (nil, error) + } + + private func tabForSidebarMutation(id: UUID) -> Tab? { + if let tab = tabManager?.tabs.first(where: { $0.id == id }) { + return tab + } + if let otherManager = AppDelegate.shared?.tabManagerFor(tabId: id) { + return otherManager.tabs.first(where: { $0.id == id }) + } + return nil + } + + private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? { + switch raw.lowercased() { + case "plain": + return .plain + case "markdown", "md": + return .markdown + default: + return nil + } + } + + private func normalizedOptionValue(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func upsertSidebarMetadata(_ args: String, missingError: String) -> String { guard tabManager != nil else { return "ERROR: TabManager not available" } let parsed = parseOptionsNoStop(args) - guard parsed.positional.count >= 2 else { - return "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X]" - } + guard parsed.positional.count >= 2 else { return missingError } + let key = parsed.positional[0] let value = parsed.positional[1...].joined(separator: " ") - let icon = parsed.options["icon"] - let color = parsed.options["color"] + let icon = normalizedOptionValue(parsed.options["icon"]) + let color = normalizedOptionValue(parsed.options["color"]) - var result = "OK" - DispatchQueue.main.sync { - guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" - return + let formatRaw = normalizedOptionValue(parsed.options["format"]) ?? SidebarMetadataFormat.plain.rawValue + guard let format = parseSidebarMetadataFormat(formatRaw) else { + return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown" + } + + let priority: Int + if let rawPriority = normalizedOptionValue(parsed.options["priority"]) { + guard let parsedPriority = Int(rawPriority) else { + return "ERROR: Invalid metadata priority '\(rawPriority)' — must be an integer" } + priority = max(-9999, min(9999, parsedPriority)) + } else { + priority = 0 + } + + let parsedURL: URL? + if let rawURL = normalizedOptionValue(parsed.options["url"] ?? parsed.options["link"]) { + guard let candidate = URL(string: rawURL), + let scheme = candidate.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return "ERROR: Invalid metadata URL '\(rawURL)' — expected http(s) URL" + } + parsedURL = candidate + } else { + parsedURL = nil + } + + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } guard Self.shouldReplaceStatusEntry( current: tab.statusEntries[key], key: key, value: value, icon: icon, - color: color + color: color, + url: parsedURL, + priority: priority, + format: format ) else { return } @@ -10839,16 +12905,19 @@ class TerminalController { value: value, icon: icon, color: color, + url: parsedURL, + priority: priority, + format: format, timestamp: Date() ) } - return result + return "OK" } - private func clearStatus(_ args: String) -> String { + private func clearSidebarMetadata(_ args: String, usage: String) -> String { let parsed = parseOptions(args) guard let key = parsed.positional.first, parsed.positional.count == 1 else { - return "ERROR: Missing status key — usage: clear_status <key> [--tab=X]" + return "ERROR: Missing metadata key — usage: \(usage)" } var result = "OK" @@ -10864,24 +12933,173 @@ class TerminalController { return result } - private func listStatus(_ args: String) -> String { + private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String { + var line = "\(entry.key)=\(entry.value)" + if let icon = entry.icon { line += " icon=\(icon)" } + if let color = entry.color { line += " color=\(color)" } + if let url = entry.url { line += " url=\(url.absoluteString)" } + if entry.priority != 0 { line += " priority=\(entry.priority)" } + if entry.format != .plain { line += " format=\(entry.format.rawValue)" } + return line + } + + private func listSidebarMetadata(_ args: String, emptyMessage: String) -> String { var result = "" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { result = "ERROR: Tab not found" return } - if tab.statusEntries.isEmpty { - result = "No status entries" + let entries = tab.sidebarStatusEntriesInDisplayOrder() + if entries.isEmpty { + result = emptyMessage return } - let lines = tab.statusEntries.values.sorted(by: { $0.key < $1.key }).map { entry in - var line = "\(entry.key)=\(entry.value)" - if let icon = entry.icon { line += " icon=\(icon)" } - if let color = entry.color { line += " color=\(color)" } - return line + result = entries.map(sidebarMetadataLine).joined(separator: "\n") + } + return result + } + + private func setStatus(_ args: String) -> String { + upsertSidebarMetadata( + args, + missingError: "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]" + ) + } + + private func reportMeta(_ args: String) -> String { + upsertSidebarMetadata( + args, + missingError: "ERROR: Missing metadata key or value — usage: report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]" + ) + } + + private func clearStatus(_ args: String) -> String { + clearSidebarMetadata(args, usage: "clear_status <key> [--tab=X]") + } + + private func clearMeta(_ args: String) -> String { + clearSidebarMetadata(args, usage: "clear_meta <key> [--tab=X]") + } + + private func listStatus(_ args: String) -> String { + listSidebarMetadata(args, emptyMessage: "No status entries") + } + + private func listMeta(_ args: String) -> String { + listSidebarMetadata(args, emptyMessage: "No metadata entries") + } + + private func splitMetadataBlockArgs(_ args: String) -> (optionsPart: String, markdownPart: String?) { + guard let separatorRange = args.range(of: " -- ") else { + return (args, nil) + } + let optionsPart = String(args[..<separatorRange.lowerBound]) + let markdownPart = String(args[separatorRange.upperBound...]) + return (optionsPart, markdownPart) + } + + private func sidebarMetadataBlockLine(_ block: SidebarMetadataBlock) -> String { + var line = "\(block.key)=\(block.markdown.replacingOccurrences(of: "\n", with: "\\n"))" + if block.priority != 0 { line += " priority=\(block.priority)" } + return line + } + + private func reportMetaBlock(_ args: String) -> String { + guard tabManager != nil else { return "ERROR: TabManager not available" } + + let parts = splitMetadataBlockArgs(args) + let parsed = parseOptionsNoStop(parts.optionsPart) + guard let key = parsed.positional.first, !key.isEmpty else { + return "ERROR: Missing metadata block key — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>" + } + + let markdown: String + if let raw = parts.markdownPart { + markdown = raw + } else if parsed.positional.count >= 2 { + markdown = parsed.positional.dropFirst().joined(separator: " ") + } else { + return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>" + } + + let normalizedMarkdown = markdown + .replacingOccurrences(of: "\\r\\n", with: "\n") + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\t", with: "\t") + + let trimmedMarkdown = normalizedMarkdown.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedMarkdown.isEmpty else { + return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>" + } + + let priority: Int + if let rawPriority = normalizedOptionValue(parsed.options["priority"]) { + guard let parsedPriority = Int(rawPriority) else { + return "ERROR: Invalid metadata block priority '\(rawPriority)' — must be an integer" } - result = lines.joined(separator: "\n") + priority = max(-9999, min(9999, parsedPriority)) + } else { + priority = 0 + } + + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: parts.optionsPart, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + guard Self.shouldReplaceMetadataBlock( + current: tab.metadataBlocks[key], + key: key, + markdown: normalizedMarkdown, + priority: priority + ) else { + return + } + tab.metadataBlocks[key] = SidebarMetadataBlock( + key: key, + markdown: normalizedMarkdown, + priority: priority, + timestamp: Date() + ) + } + return "OK" + } + + private func clearMetaBlock(_ args: String) -> String { + let parsed = parseOptions(args) + guard let key = parsed.positional.first, parsed.positional.count == 1 else { + return "ERROR: Missing metadata block key — usage: clear_meta_block <key> [--tab=X]" + } + + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + if tab.metadataBlocks.removeValue(forKey: key) == nil { + result = "OK (key not found)" + } + } + return result + } + + private func listMetaBlocks(_ args: String) -> String { + var result = "" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = "ERROR: Tab not found" + return + } + let blocks = tab.sidebarMetadataBlocksInDisplayOrder() + if blocks.isEmpty { + result = "No metadata blocks" + return + } + result = blocks.map(sidebarMetadataBlockLine).joined(separator: "\n") } return result } @@ -11012,6 +13230,23 @@ class TerminalController { } let isDirty = parsed.options["status"]?.lowercased() == "dirty" + // Shell integration always includes explicit workspace/panel IDs. + // Keep this telemetry path off-main so wake/main-thread stalls don't + // block socket handlers and starve subsequent branch updates. + if let scope = Self.explicitSocketScope(options: parsed.options) { + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), + let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } + tab.updatePanelGitBranch(panelId: scope.panelId, branch: branch, isDirty: isDirty) + } + return "OK" + } + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -11053,6 +13288,24 @@ class TerminalController { private func clearGitBranch(_ args: String) -> String { let parsed = parseOptions(args) + + // Shell integration always includes explicit workspace/panel IDs. + // Keep this telemetry path off-main so wake/main-thread stalls don't + // block socket handlers and starve subsequent branch updates. + if let scope = Self.explicitSocketScope(options: parsed.options) { + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), + let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } + tab.clearPanelGitBranch(panelId: scope.panelId) + } + return "OK" + } + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -11092,6 +13345,132 @@ class TerminalController { return result } + private func reportPullRequest(_ args: String) -> String { + let parsed = parseOptions(args) + guard parsed.positional.count >= 2 else { + return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + } + + let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines) + let numberToken = rawNumber.hasPrefix("#") ? String(rawNumber.dropFirst()) : rawNumber + guard let number = Int(numberToken), number > 0 else { + return "ERROR: Invalid pull request number '\(rawNumber)'" + } + + let rawURL = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: rawURL), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return "ERROR: Invalid pull request URL '\(rawURL)'" + } + + let statusRaw = (parsed.options["state"] ?? "open").lowercased() + guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else { + return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed" + } + + let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR" + guard !labelRaw.isEmpty else { + return "ERROR: Invalid review label — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + } + let label = String(labelRaw.prefix(16)) + + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + guard Self.shouldReplacePullRequest( + current: tab.panelPullRequests[surfaceId], + number: number, + label: label, + url: url, + status: status + ) else { + return + } + + tab.updatePanelPullRequest( + panelId: surfaceId, + number: number, + label: label, + url: url, + status: status + ) + } + return result + } + + private func clearPullRequest(_ args: String) -> String { + let parsed = parseOptions(args) + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + tab.clearPanelPullRequest(panelId: surfaceId) + } + return result + } + private func reportPorts(_ args: String) -> String { let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { @@ -11366,6 +13745,7 @@ class TerminalController { var lines: [String] = [] lines.append("tab=\(tab.id.uuidString)") + lines.append("color=\(tab.customColor ?? "none")") lines.append("cwd=\(tab.currentDirectory)") if let focused = tab.focusedPanelId, @@ -11383,6 +13763,14 @@ class TerminalController { lines.append("git_branch=none") } + if let pr = tab.pullRequest { + lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)") + lines.append("pr_label=\(pr.label)") + } else { + lines.append("pr=none") + lines.append("pr_label=none") + } + if tab.listeningPorts.isEmpty { lines.append("ports=none") } else { @@ -11396,12 +13784,16 @@ class TerminalController { lines.append("progress=none") } - lines.append("status_count=\(tab.statusEntries.count)") - for entry in tab.statusEntries.values.sorted(by: { $0.key < $1.key }) { - var line = " \(entry.key)=\(entry.value)" - if let icon = entry.icon { line += " icon=\(icon)" } - if let color = entry.color { line += " color=\(color)" } - lines.append(line) + let statusEntries = tab.sidebarStatusEntriesInDisplayOrder() + lines.append("status_count=\(statusEntries.count)") + for entry in statusEntries { + lines.append(" \(sidebarMetadataLine(entry))") + } + + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + lines.append("meta_block_count=\(metadataBlocks.count)") + for block in metadataBlocks { + lines.append(" \(sidebarMetadataBlockLine(block))") } lines.append("log_count=\(tab.logEntries.count)") @@ -11421,13 +13813,7 @@ class TerminalController { result = "ERROR: Tab not found" return } - tab.statusEntries.removeAll() - tab.logEntries.removeAll() - tab.progress = nil - tab.gitBranch = nil - tab.panelGitBranches.removeAll() - tab.surfaceListeningPorts.removeAll() - tab.listeningPorts.removeAll() + tab.resetSidebarContext(reason: "reset_sidebar") } return result } @@ -11446,7 +13832,7 @@ class TerminalController { // (resets cached metrics so the Metal layer drawable resizes correctly) for panel in tab.panels.values { if let terminalPanel = panel as? TerminalPanel { - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.refreshAllTerminalPanels") refreshedCount += 1 } } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 35060ebe..fb1f0b90 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1,6 +1,519 @@ import AppKit import Foundation import UserNotifications +import Bonsplit + +// UNUserNotificationCenter.removeDeliveredNotifications(withIdentifiers:) and +// removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to +// usernoted under the hood. When usernoted is slow, this blocks the calling thread +// indefinitely. These helpers dispatch the calls off the main thread so they never +// freeze the UI. +extension UNUserNotificationCenter { + private static let removalQueue = DispatchQueue( + label: "com.cmuxterm.notification-removal", + qos: .utility + ) + + func removeDeliveredNotificationsOffMain(withIdentifiers ids: [String]) { + guard !ids.isEmpty else { return } + Self.removalQueue.async { + self.removeDeliveredNotifications(withIdentifiers: ids) + } + } + + func removePendingNotificationRequestsOffMain(withIdentifiers ids: [String]) { + guard !ids.isEmpty else { return } + Self.removalQueue.async { + self.removePendingNotificationRequests(withIdentifiers: ids) + } + } +} + +enum NotificationSoundSettings { + static let key = "notificationSound" + static let defaultValue = "default" + static let customFileValue = "custom_file" + static let customFilePathKey = "notificationSoundCustomFilePath" + static let defaultCustomFilePath = "" + private static let stagedCustomSoundBaseName = "cmux-custom-notification-sound" + private static let customSoundPreparationQueue = DispatchQueue( + label: "com.cmuxterm.notification-sound-preparation", + qos: .utility + ) + private static let pendingCustomSoundPreparationLock = NSLock() + private static var pendingCustomSoundPreparationPaths: Set<String> = [] + private static let notificationSoundSupportedExtensions: Set<String> = [ + "aif", + "aiff", + "caf", + "wav", + ] + + private struct CustomSoundSourceMetadata: Codable, Equatable { + let sourcePath: String + let sourceSize: UInt64 + let sourceModificationTime: Double + let sourceFileIdentifier: UInt64? + } + + enum CustomSoundPreparationIssue: Error { + case emptyPath + case missingFile(path: String) + case missingFileExtension(path: String) + case stagingFailed(path: String, details: String) + + var logMessage: String { + switch self { + case .emptyPath: + return "Notification custom sound path is empty" + case .missingFile(let path): + return "Notification custom sound file does not exist: \(path)" + case .missingFileExtension(let path): + return "Notification custom sound requires a file extension: \(path)" + case .stagingFailed(let path, let details): + return "Failed to stage custom notification sound from \(path): \(details)" + } + } + } + static let customCommandKey = "notificationCustomCommand" + static let defaultCustomCommand = "" + + static let systemSounds: [(label: String, value: String)] = [ + ("Default", "default"), + ("Basso", "Basso"), + ("Blow", "Blow"), + ("Bottle", "Bottle"), + ("Frog", "Frog"), + ("Funk", "Funk"), + ("Glass", "Glass"), + ("Hero", "Hero"), + ("Morse", "Morse"), + ("Ping", "Ping"), + ("Pop", "Pop"), + ("Purr", "Purr"), + ("Sosumi", "Sosumi"), + ("Submarine", "Submarine"), + ("Tink", "Tink"), + ("Custom File...", customFileValue), + ("None", "none"), + ] + + static func sound(defaults: UserDefaults = .standard) -> UNNotificationSound? { + let value = defaults.string(forKey: key) ?? defaultValue + switch value { + case "default": + return .default + case "none": + return nil + case customFileValue: + guard let customSoundName = stagedCustomSoundName(defaults: defaults) else { + return nil + } + return UNNotificationSound(named: UNNotificationSoundName(rawValue: customSoundName)) + default: + return UNNotificationSound(named: UNNotificationSoundName(rawValue: value)) + } + } + + static func usesSystemSound(defaults: UserDefaults = .standard) -> Bool { + let value = defaults.string(forKey: key) ?? defaultValue + switch value { + case "none": + return false + case customFileValue: + return customFileURL(defaults: defaults) != nil + default: + return true + } + } + + static func isSilent(defaults: UserDefaults = .standard) -> Bool { + return (defaults.string(forKey: key) ?? defaultValue) == "none" + } + + static func isCustomFileSelected(defaults: UserDefaults = .standard) -> Bool { + (defaults.string(forKey: key) ?? defaultValue) == customFileValue + } + + static func stagedCustomSoundName(defaults: UserDefaults = .standard) -> String? { + let rawPath = defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath + guard let normalizedPath = normalizedCustomFilePath(rawPath) else { + NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.emptyPath.logMessage)") + return nil + } + + let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) + let sourceExtension = sourceURL.pathExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !sourceExtension.isEmpty else { + NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFileExtension(path: sourceURL.path).logMessage)") + return nil + } + + let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) + let stagedFileName = stagedCustomSoundFileName( + forSourceURL: sourceURL, + destinationExtension: destinationExtension + ) + let stagedURL = stagedSoundDirectoryURL().appendingPathComponent(stagedFileName, isDirectory: false) + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: sourceURL.path) else { + NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFile(path: sourceURL.path).logMessage)") + return nil + } + + if fileManager.fileExists(atPath: stagedURL.path) { + if let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager), + let stagedMetadata = loadStagedSourceMetadata(for: stagedURL), + stagedMetadata == sourceMetadata { + return stagedFileName + } + } + + if destinationExtension == sourceExtension { + switch prepareCustomFileForNotifications(path: normalizedPath) { + case .success(let preparedName): + return preparedName + case .failure(let issue): + NSLog("Notification custom sound unavailable: \(issue.logMessage)") + return nil + } + } + + queueCustomSoundPreparation(path: normalizedPath) + NSLog("Notification custom sound not ready yet, staging in background: \(sourceURL.path)") + return nil + } + + static func prepareCustomFileForNotifications(path: String) -> Result<String, CustomSoundPreparationIssue> { + guard let normalizedPath = normalizedCustomFilePath(path) else { + return .failure(.emptyPath) + } + let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) + return prepareCustomSound(from: sourceURL) + } + + private static func prepareCustomSound(from sourceURL: URL) -> Result<String, CustomSoundPreparationIssue> { + let sourcePath = sourceURL.path + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: sourcePath) else { + return .failure(.missingFile(path: sourcePath)) + } + let sourceExtension = sourceURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceExtension.isEmpty else { + return .failure(.missingFileExtension(path: sourcePath)) + } + let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) + + let destinationDirectory = stagedSoundDirectoryURL() + let destinationFileName = stagedCustomSoundFileName( + forSourceURL: sourceURL, + destinationExtension: destinationExtension + ) + let destinationURL = destinationDirectory.appendingPathComponent(destinationFileName, isDirectory: false) + let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager) + + do { + try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) + if fileManager.fileExists(atPath: destinationURL.path) { + let stagedMetadata = loadStagedSourceMetadata(for: destinationURL) + if stagedMetadata != sourceMetadata { + try? fileManager.removeItem(at: destinationURL) + } + } + if destinationExtension == sourceExtension.lowercased() { + try copyStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager) + } else { + try transcodeStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager) + } + if let sourceMetadata { + try saveStagedSourceMetadata(sourceMetadata, for: destinationURL) + } + try cleanupStaleStagedSoundFiles( + in: destinationDirectory, + keeping: destinationFileName, + preservingSourceURL: sourceURL, + fileManager: fileManager + ) + return .success(destinationFileName) + } catch { + return .failure(.stagingFailed(path: sourcePath, details: error.localizedDescription)) + } + } + + static func customFileURL(defaults: UserDefaults = .standard) -> URL? { + guard let path = normalizedCustomFilePath(defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath) else { + return nil + } + return URL(fileURLWithPath: (path as NSString).expandingTildeInPath) + } + + static func playCustomFileSound(defaults: UserDefaults = .standard) { + guard let url = customFileURL(defaults: defaults) else { return } + playSoundFile(at: url) + } + + static func playCustomFileSound(path: String) { + guard let normalizedPath = normalizedCustomFilePath(path) else { return } + let url = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) + playSoundFile(at: url) + } + + static func previewSound(value: String, defaults: UserDefaults = .standard) { + switch value { + case "default": + NSSound.beep() + case "none": + break + case customFileValue: + playCustomFileSound(defaults: defaults) + default: + NSSound(named: NSSound.Name(value))?.play() + } + } + + static func stagedCustomSoundFileExtension(forSourceExtension sourceExtension: String) -> String { + let normalized = sourceExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !normalized.isEmpty else { return "caf" } + if notificationSoundSupportedExtensions.contains(normalized) { + return normalized + } + return "caf" + } + + static func stagedCustomSoundFileName(forSourceURL sourceURL: URL, destinationExtension: String) -> String { + let normalizedExtension = destinationExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let ext = normalizedExtension.isEmpty ? "caf" : normalizedExtension + let signature = stagedCustomSoundSourceSignature(for: sourceURL) + return "\(stagedCustomSoundBaseName)-\(signature).\(ext)" + } + + private static func normalizedCustomFilePath(_ rawPath: String) -> String? { + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + + private static func stagedSoundDirectoryURL() -> URL { + URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + } + + private static func queueCustomSoundPreparation(path: String) { + let expandedPath = (path as NSString).expandingTildeInPath + pendingCustomSoundPreparationLock.lock() + if pendingCustomSoundPreparationPaths.contains(expandedPath) { + pendingCustomSoundPreparationLock.unlock() + return + } + pendingCustomSoundPreparationPaths.insert(expandedPath) + pendingCustomSoundPreparationLock.unlock() + + customSoundPreparationQueue.async { + defer { + pendingCustomSoundPreparationLock.lock() + pendingCustomSoundPreparationPaths.remove(expandedPath) + pendingCustomSoundPreparationLock.unlock() + } + _ = prepareCustomFileForNotifications(path: expandedPath) + } + } + + private static func playSoundFile(at url: URL) { + DispatchQueue.main.async { + guard let sound = NSSound(contentsOf: url, byReference: false) else { + NSLog("Notification custom sound failed to load from path: \(url.path)") + return + } + sound.play() + } + } + + private static func cleanupStaleStagedSoundFiles( + in directoryURL: URL, + keeping fileName: String, + preservingSourceURL: URL, + fileManager: FileManager + ) throws { + let legacyPrefix = "\(stagedCustomSoundBaseName)." + let hashedPrefix = "\(stagedCustomSoundBaseName)-" + let normalizedSource = preservingSourceURL.standardizedFileURL + let keptStagedURL = directoryURL.appendingPathComponent(fileName, isDirectory: false) + let keptMetadataFileName = stagedSourceMetadataURL(for: keptStagedURL).lastPathComponent + for fileNameCandidate in try fileManager.contentsOfDirectory(atPath: directoryURL.path) { + let isManagedName = fileNameCandidate.hasPrefix(legacyPrefix) || fileNameCandidate.hasPrefix(hashedPrefix) + let isKeptManagedFile = fileNameCandidate == fileName || fileNameCandidate == keptMetadataFileName + guard isManagedName, !isKeptManagedFile else { continue } + let staleURL = directoryURL.appendingPathComponent(fileNameCandidate, isDirectory: false) + if staleURL.standardizedFileURL == normalizedSource { + continue + } + try? fileManager.removeItem(at: staleURL) + try? fileManager.removeItem(at: stagedSourceMetadataURL(for: staleURL)) + } + } + + private static func copyStagedSoundIfNeeded( + from sourceURL: URL, + to destinationURL: URL, + fileManager: FileManager + ) throws { + let normalizedSource = sourceURL.standardizedFileURL + let normalizedDestination = destinationURL.standardizedFileURL + guard normalizedSource != normalizedDestination else { return } + + if fileManager.fileExists(atPath: normalizedDestination.path) { + let sourceAttributes = try fileManager.attributesOfItem(atPath: normalizedSource.path) + let destinationAttributes = try fileManager.attributesOfItem(atPath: normalizedDestination.path) + let sourceSize = sourceAttributes[.size] as? NSNumber + let destinationSize = destinationAttributes[.size] as? NSNumber + let sourceDate = sourceAttributes[.modificationDate] as? Date + let destinationDate = destinationAttributes[.modificationDate] as? Date + if sourceSize == destinationSize && sourceDate == destinationDate { + return + } + try fileManager.removeItem(at: normalizedDestination) + } + + try fileManager.copyItem(at: normalizedSource, to: normalizedDestination) + } + + private static func transcodeStagedSoundIfNeeded( + from sourceURL: URL, + to destinationURL: URL, + fileManager: FileManager + ) throws { + let normalizedSource = sourceURL.standardizedFileURL + let normalizedDestination = destinationURL.standardizedFileURL + guard normalizedSource != normalizedDestination else { return } + + if fileManager.fileExists(atPath: normalizedDestination.path) { + let sourceAttributes = try fileManager.attributesOfItem(atPath: normalizedSource.path) + let destinationAttributes = try fileManager.attributesOfItem(atPath: normalizedDestination.path) + let sourceDate = sourceAttributes[.modificationDate] as? Date + let destinationDate = destinationAttributes[.modificationDate] as? Date + if let sourceDate, let destinationDate, destinationDate >= sourceDate { + return + } + try fileManager.removeItem(at: normalizedDestination) + } + + let outputPipe = Pipe() + let errorPipe = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert") + process.arguments = [ + "-f", "caff", + "-d", "LEI16", + normalizedSource.path, + normalizedDestination.path, + ] + process.standardOutput = outputPipe + process.standardError = errorPipe + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorOutput = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if fileManager.fileExists(atPath: normalizedDestination.path) { + try? fileManager.removeItem(at: normalizedDestination) + } + let description: String + if let errorOutput, !errorOutput.isEmpty { + description = errorOutput + } else { + description = "afconvert failed with exit code \(process.terminationStatus)" + } + throw NSError( + domain: "NotificationSoundSettings", + code: Int(process.terminationStatus), + userInfo: [ + NSLocalizedDescriptionKey: description, + ] + ) + } + } + + private static func stagedCustomSoundSourceSignature(for sourceURL: URL) -> String { + let normalizedPath = sourceURL.standardizedFileURL.path + var hash: UInt64 = 0xcbf29ce484222325 + for byte in normalizedPath.utf8 { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + return String(format: "%016llx", hash) + } + + private static func stagedSourceMetadataURL(for stagedURL: URL) -> URL { + stagedURL.appendingPathExtension("source-metadata") + } + + private static func currentSourceMetadata(for sourceURL: URL, fileManager: FileManager) -> CustomSoundSourceMetadata? { + guard let attributes = try? fileManager.attributesOfItem(atPath: sourceURL.path) else { + return nil + } + guard let sourceSizeNumber = attributes[.size] as? NSNumber else { + return nil + } + let sourceDate = (attributes[.modificationDate] as? Date) ?? .distantPast + let fileIdentifier = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value + return CustomSoundSourceMetadata( + sourcePath: sourceURL.standardizedFileURL.path, + sourceSize: sourceSizeNumber.uint64Value, + sourceModificationTime: sourceDate.timeIntervalSinceReferenceDate, + sourceFileIdentifier: fileIdentifier + ) + } + + private static func loadStagedSourceMetadata(for stagedURL: URL) -> CustomSoundSourceMetadata? { + let metadataURL = stagedSourceMetadataURL(for: stagedURL) + guard let data = try? Data(contentsOf: metadataURL) else { + return nil + } + return try? JSONDecoder().decode(CustomSoundSourceMetadata.self, from: data) + } + + private static func saveStagedSourceMetadata(_ metadata: CustomSoundSourceMetadata, for stagedURL: URL) throws { + let metadataURL = stagedSourceMetadataURL(for: stagedURL) + let data = try JSONEncoder().encode(metadata) + try data.write(to: metadataURL, options: .atomic) + } + + private static let customCommandQueue = DispatchQueue( + label: "com.cmuxterm.notification-custom-command", + qos: .utility + ) + + static func runCustomCommand(title: String, subtitle: String, body: String, defaults: UserDefaults = .standard) { + let command = (defaults.string(forKey: customCommandKey) ?? defaultCustomCommand) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !command.isEmpty else { return } + customCommandQueue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + var env = ProcessInfo.processInfo.environment + env["CMUX_NOTIFICATION_TITLE"] = title + env["CMUX_NOTIFICATION_SUBTITLE"] = subtitle + env["CMUX_NOTIFICATION_BODY"] = body + process.environment = env + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + NSLog("Notification command failed to launch: \(error)") + } + } + } +} enum NotificationBadgeSettings { static let dockBadgeEnabledKey = "notificationDockBadgeEnabled" @@ -58,6 +571,39 @@ enum AppFocusState { } } +enum NotificationAuthorizationState: Equatable { + case unknown + case notDetermined + case authorized + case denied + case provisional + case ephemeral + + var statusLabel: String { + switch self { + case .unknown, .notDetermined: + return "Not Requested" + case .authorized: + return "Allowed" + case .denied: + return "Denied" + case .provisional: + return "Deliver Quietly" + case .ephemeral: + return "Temporary" + } + } + + var allowsDelivery: Bool { + switch self { + case .authorized, .provisional, .ephemeral: + return true + case .unknown, .notDetermined, .denied: + return false + } + } +} + struct TerminalNotification: Identifiable, Hashable { let id: UUID let tabId: UUID @@ -71,23 +617,69 @@ struct TerminalNotification: Identifiable, Hashable { @MainActor final class TerminalNotificationStore: ObservableObject { + private struct TabSurfaceKey: Hashable { + let tabId: UUID + let surfaceId: UUID? + } + + private struct NotificationIndexes { + var unreadCount = 0 + var unreadCountByTabId: [UUID: Int] = [:] + var unreadByTabSurface = Set<TabSurfaceKey>() + var latestUnreadByTabId: [UUID: TerminalNotification] = [:] + var latestByTabId: [UUID: TerminalNotification] = [:] + } + static let shared = TerminalNotificationStore() static let categoryIdentifier = "com.cmuxterm.app.userNotification" static let actionShowIdentifier = "com.cmuxterm.app.userNotification.show" + private enum AuthorizationRequestOrigin: String { + case notificationDelivery = "notification_delivery" + case settingsButton = "settings_button" + case settingsTest = "settings_test" + } @Published private(set) var notifications: [TerminalNotification] = [] { didSet { + indexes = Self.buildIndexes(for: notifications) refreshDockBadge() } } + @Published private(set) var authorizationState: NotificationAuthorizationState = .unknown private let center = UNUserNotificationCenter.current() - private var hasRequestedAuthorization = false + private var hasRequestedAutomaticAuthorization = false + private var hasDeferredAuthorizationRequest = false private var hasPromptedForSettings = false private var userDefaultsObserver: NSObjectProtocol? + private let settingsPromptWindowRetryDelay: TimeInterval = 0.5 + private let settingsPromptWindowRetryLimit = 20 + private var notificationSettingsWindowProvider: () -> NSWindow? = { + NSApp.keyWindow ?? NSApp.mainWindow + } + private var notificationSettingsAlertFactory: () -> NSAlert = { + NSAlert() + } + private var notificationSettingsScheduler: (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void = { + delay, + block in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + block() + } + } + private var notificationSettingsURLOpener: (URL) -> Void = { url in + NSWorkspace.shared.open(url) + } + private var notificationDeliveryHandler: (TerminalNotificationStore, TerminalNotification) -> Void = { + store, + notification in + store.scheduleUserNotification(notification) + } + private var indexes = NotificationIndexes() private init() { + indexes = Self.buildIndexes(for: notifications) userDefaultsObserver = NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, object: nil, @@ -96,6 +688,7 @@ final class TerminalNotificationStore: ObservableObject { self?.refreshDockBadge() } refreshDockBadge() + refreshAuthorizationStatus() } deinit { @@ -124,38 +717,131 @@ final class TerminalNotificationStore: ObservableObject { } var unreadCount: Int { - notifications.filter { !$0.isRead }.count + indexes.unreadCount + } + + private func logAuthorization(_ message: String) { +#if DEBUG + dlog("notification.auth \(message)") +#endif + NSLog("notification.auth %@", message) + } + + private static func authorizationStatusLabel(_ status: UNAuthorizationStatus) -> String { + switch status { + case .notDetermined: + return "notDetermined" + case .denied: + return "denied" + case .authorized: + return "authorized" + case .provisional: + return "provisional" + case .ephemeral: + return "ephemeral" + @unknown default: + return "unknown(\(status.rawValue))" + } + } + + func refreshAuthorizationStatus() { + center.getNotificationSettings { [weak self] settings in + DispatchQueue.main.async { + guard let self else { return } + self.authorizationState = Self.authorizationState(from: settings.authorizationStatus) + self.logAuthorization( + "refresh status=\(Self.authorizationStatusLabel(settings.authorizationStatus)) mapped=\(self.authorizationState.statusLabel)" + ) + } + } + } + + func requestAuthorizationFromSettings() { + logAuthorization("settings request tapped state=\(authorizationState.statusLabel)") + ensureAuthorization(origin: .settingsButton) { _ in } + } + + func openNotificationSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else { + return + } + logAuthorization("open settings url=\(url.absoluteString)") + notificationSettingsURLOpener(url) + } + + func sendSettingsTestNotification() { + logAuthorization("settings test tapped state=\(authorizationState.statusLabel)") + ensureAuthorization(origin: .settingsTest) { [weak self] authorized in + guard let self, authorized else { return } + + let content = UNMutableNotificationContent() + content.title = "cmux test notification" + content.body = "Desktop notifications are enabled." + content.sound = NotificationSoundSettings.sound() + content.categoryIdentifier = Self.categoryIdentifier + + let request = UNNotificationRequest( + identifier: "cmux.settings.test.\(UUID().uuidString)", + content: content, + trigger: nil + ) + + self.center.add(request) { error in + if let error { + NSLog("Failed to schedule test notification: \(error)") + self.logAuthorization("settings test schedule failed error=\(error.localizedDescription)") + } else { + self.logAuthorization("settings test schedule succeeded") + NotificationSoundSettings.runCustomCommand( + title: content.title, + subtitle: content.subtitle, + body: content.body + ) + } + } + } + } + + func handleApplicationDidBecomeActive() { + logAuthorization("app became active deferred=\(hasDeferredAuthorizationRequest)") + if hasDeferredAuthorizationRequest { + hasDeferredAuthorizationRequest = false + ensureAuthorization(origin: .settingsButton) { _ in } + return + } + refreshAuthorizationStatus() } func unreadCount(forTabId tabId: UUID) -> Int { - notifications.filter { $0.tabId == tabId && !$0.isRead }.count + indexes.unreadCountByTabId[tabId] ?? 0 } func hasUnreadNotification(forTabId tabId: UUID, surfaceId: UUID?) -> Bool { - notifications.contains { $0.tabId == tabId && $0.surfaceId == surfaceId && !$0.isRead } + indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId)) } func latestNotification(forTabId tabId: UUID) -> TerminalNotification? { - if let unread = notifications.first(where: { $0.tabId == tabId && !$0.isRead }) { - return unread - } - return notifications.first(where: { $0.tabId == tabId }) + indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId] } func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) { - clearNotifications(forTabId: tabId, surfaceId: surfaceId) + var updated = notifications + var idsToClear: [String] = [] + updated.removeAll { existing in + guard existing.tabId == tabId, existing.surfaceId == surfaceId else { return false } + idsToClear.append(existing.id.uuidString) + return true + } let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId) let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId let isFocusedPanel = isActiveTab && isFocusedSurface let isAppFocused = AppFocusState.isAppFocused() - if isAppFocused && isFocusedPanel { - return - } + let shouldSuppressExternalDelivery = isAppFocused && isFocusedPanel if WorkspaceAutoReorderSettings.isEnabled() { - AppDelegate.shared?.tabManager?.moveTabToTop(tabId) + AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId) } let notification = TerminalNotification( @@ -168,105 +854,142 @@ final class TerminalNotificationStore: ObservableObject { createdAt: Date(), isRead: false ) - notifications.insert(notification, at: 0) - scheduleUserNotification(notification) + updated.insert(notification, at: 0) + notifications = updated + if !idsToClear.isEmpty { + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) + } + if !shouldSuppressExternalDelivery { + notificationDeliveryHandler(self, notification) + } } func markRead(id: UUID) { - guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } - if notifications[index].isRead { return } - notifications[index].isRead = true - center.removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + var updated = notifications + guard let index = updated.firstIndex(where: { $0.id == id }) else { return } + guard !updated[index].isRead else { return } + updated[index].isRead = true + notifications = updated + center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) } func markRead(forTabId tabId: UUID) { + var updated = notifications var idsToClear: [String] = [] - for index in notifications.indices { - if notifications[index].tabId == tabId && !notifications[index].isRead { - notifications[index].isRead = true - idsToClear.append(notifications[index].id.uuidString) + for index in updated.indices { + if updated[index].tabId == tabId && !updated[index].isRead { + updated[index].isRead = true + idsToClear.append(updated[index].id.uuidString) } } if !idsToClear.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: idsToClear) + notifications = updated + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) } } func markRead(forTabId tabId: UUID, surfaceId: UUID?) { + var updated = notifications var idsToClear: [String] = [] - for index in notifications.indices { - if notifications[index].tabId == tabId, - notifications[index].surfaceId == surfaceId, - !notifications[index].isRead { - notifications[index].isRead = true - idsToClear.append(notifications[index].id.uuidString) + for index in updated.indices { + if updated[index].tabId == tabId, + updated[index].surfaceId == surfaceId, + !updated[index].isRead { + updated[index].isRead = true + idsToClear.append(updated[index].id.uuidString) } } if !idsToClear.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + notifications = updated + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } } func markUnread(forTabId tabId: UUID) { - for index in notifications.indices { - if notifications[index].tabId == tabId { - notifications[index].isRead = false + var updated = notifications + var didChange = false + for index in updated.indices { + if updated[index].tabId == tabId, updated[index].isRead { + updated[index].isRead = false + didChange = true } } + if didChange { + notifications = updated + } } func markAllRead() { + var updated = notifications var idsToClear: [String] = [] - for index in notifications.indices { - if !notifications[index].isRead { - notifications[index].isRead = true - idsToClear.append(notifications[index].id.uuidString) + for index in updated.indices { + if !updated[index].isRead { + updated[index].isRead = true + idsToClear.append(updated[index].id.uuidString) } } if !idsToClear.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + notifications = updated + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } } func remove(id: UUID) { - notifications.removeAll { $0.id == id } - center.removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + var updated = notifications + let originalCount = updated.count + updated.removeAll { $0.id == id } + guard updated.count != originalCount else { return } + notifications = updated + center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) } func clearAll() { + guard !notifications.isEmpty else { return } let ids = notifications.map { $0.id.uuidString } notifications.removeAll() - if !ids.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: ids) - } + center.removeDeliveredNotificationsOffMain(withIdentifiers: ids) + center.removePendingNotificationRequestsOffMain(withIdentifiers: ids) } func clearNotifications(forTabId tabId: UUID, surfaceId: UUID?) { - let ids = notifications - .filter { $0.tabId == tabId && $0.surfaceId == surfaceId } - .map { $0.id.uuidString } - notifications.removeAll { $0.tabId == tabId && $0.surfaceId == surfaceId } - if !ids.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: ids) - center.removePendingNotificationRequests(withIdentifiers: ids) + var updated: [TerminalNotification] = [] + updated.reserveCapacity(notifications.count) + var idsToClear: [String] = [] + for notification in notifications { + if notification.tabId == tabId, notification.surfaceId == surfaceId { + idsToClear.append(notification.id.uuidString) + } else { + updated.append(notification) + } } + guard !idsToClear.isEmpty else { return } + notifications = updated + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } func clearNotifications(forTabId tabId: UUID) { - let ids = notifications - .filter { $0.tabId == tabId } - .map { $0.id.uuidString } - notifications.removeAll { $0.tabId == tabId } - if !ids.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: ids) - center.removePendingNotificationRequests(withIdentifiers: ids) + var updated: [TerminalNotification] = [] + updated.reserveCapacity(notifications.count) + var idsToClear: [String] = [] + for notification in notifications { + if notification.tabId == tabId { + idsToClear.append(notification.id.uuidString) + } else { + updated.append(notification) + } } + guard !idsToClear.isEmpty else { return } + notifications = updated + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } private func scheduleUserNotification(_ notification: TerminalNotification) { - ensureAuthorization { [weak self] authorized in + ensureAuthorization(origin: .notificationDelivery) { [weak self] authorized in guard let self, authorized else { return } let content = UNMutableNotificationContent() @@ -276,7 +999,7 @@ final class TerminalNotificationStore: ObservableObject { content.title = notification.title.isEmpty ? appName : notification.title content.subtitle = notification.subtitle content.body = notification.body - content.sound = UNNotificationSound.default + content.sound = NotificationSoundSettings.sound() content.categoryIdentifier = Self.categoryIdentifier content.userInfo = [ "tabId": notification.tabId.uuidString, @@ -295,61 +1018,242 @@ final class TerminalNotificationStore: ObservableObject { self.center.add(request) { error in if let error { NSLog("Failed to schedule notification: \(error)") + } else { + NotificationSoundSettings.runCustomCommand( + title: content.title, + subtitle: content.subtitle, + body: content.body + ) } } } } - private func ensureAuthorization(_ completion: @escaping (Bool) -> Void) { + private func ensureAuthorization( + origin: AuthorizationRequestOrigin, + _ completion: @escaping (Bool) -> Void + ) { + logAuthorization("ensure start origin=\(origin.rawValue)") center.getNotificationSettings { [weak self] settings in - guard let self else { - completion(false) - return - } + DispatchQueue.main.async { + guard let self else { + completion(false) + return + } - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - completion(true) - case .denied: - self.promptToEnableNotifications() - completion(false) - case .notDetermined: - self.requestAuthorizationIfNeeded(completion) - @unknown default: - completion(false) + self.authorizationState = Self.authorizationState(from: settings.authorizationStatus) + self.logAuthorization( + "ensure status origin=\(origin.rawValue) status=\(Self.authorizationStatusLabel(settings.authorizationStatus)) mapped=\(self.authorizationState.statusLabel) appActive=\(AppFocusState.isAppActive())" + ) + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + completion(true) + case .denied: + self.logAuthorization("ensure denied origin=\(origin.rawValue) prompting_settings") + self.promptToEnableNotifications() + completion(false) + case .notDetermined: + if Self.shouldDeferAutomaticAuthorizationRequest( + origin: origin, + status: settings.authorizationStatus, + isAppActive: AppFocusState.isAppActive() + ) { + self.logAuthorization("ensure deferred origin=\(origin.rawValue)") + self.hasDeferredAuthorizationRequest = true + completion(false) + } else { + self.requestAuthorizationIfNeeded(origin: origin, completion) + } + @unknown default: + self.logAuthorization("ensure unknown status origin=\(origin.rawValue)") + completion(false) + } } } } - private func requestAuthorizationIfNeeded(_ completion: @escaping (Bool) -> Void) { - guard !hasRequestedAuthorization else { + private func requestAuthorizationIfNeeded( + origin: AuthorizationRequestOrigin, + _ completion: @escaping (Bool) -> Void + ) { + let isAutomaticRequest = origin == .notificationDelivery + guard Self.shouldRequestAuthorization( + isAutomaticRequest: isAutomaticRequest, + hasRequestedAutomaticAuthorization: hasRequestedAutomaticAuthorization + ) else { + logAuthorization( + "request blocked origin=\(origin.rawValue) automatic=\(isAutomaticRequest) hasRequestedAutomatic=\(hasRequestedAutomaticAuthorization)" + ) completion(false) return } - hasRequestedAuthorization = true - center.requestAuthorization(options: [.alert, .sound]) { granted, _ in - completion(granted) + if isAutomaticRequest { + hasRequestedAutomaticAuthorization = true + } + hasDeferredAuthorizationRequest = false + logAuthorization( + "request starting origin=\(origin.rawValue) automatic=\(isAutomaticRequest) hasRequestedAutomatic=\(hasRequestedAutomaticAuthorization)" + ) + center.requestAuthorization(options: [.alert, .sound]) { granted, error in + DispatchQueue.main.async { + if granted { + self.authorizationState = .authorized + } else { + self.refreshAuthorizationStatus() + } + self.logAuthorization( + "request callback origin=\(origin.rawValue) granted=\(granted) error=\(error?.localizedDescription ?? "nil") mapped=\(self.authorizationState.statusLabel)" + ) + completion(granted) + } } } private func promptToEnableNotifications() { DispatchQueue.main.async { [weak self] in guard let self, !self.hasPromptedForSettings else { return } + self.logAuthorization("prompt settings shown") self.hasPromptedForSettings = true - - let alert = NSAlert() - alert.messageText = "Enable Notifications for cmux" - alert.informativeText = "Notifications are disabled for cmux. Enable them in System Settings to see alerts." - alert.addButton(withTitle: "Open Settings") - alert.addButton(withTitle: "Not Now") - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { - NSWorkspace.shared.open(url) - } + self.presentNotificationSettingsPrompt(attempt: 0) } } + private func presentNotificationSettingsPrompt(attempt: Int) { + guard let window = notificationSettingsWindowProvider() else { + guard attempt < settingsPromptWindowRetryLimit else { + // If no window is available after retries, allow a future denied callback + // to prompt again when the app has a key/main window. + hasPromptedForSettings = false + return + } + notificationSettingsScheduler(settingsPromptWindowRetryDelay) { [weak self] in + self?.presentNotificationSettingsPrompt(attempt: attempt + 1) + } + return + } + + let alert = notificationSettingsAlertFactory() + alert.messageText = String(localized: "dialog.enableNotifications.title", defaultValue: "Enable Notifications for cmux") + alert.informativeText = String(localized: "dialog.enableNotifications.message", defaultValue: "Notifications are disabled for cmux. Enable them in System Settings to see alerts.") + alert.addButton(withTitle: String(localized: "dialog.enableNotifications.openSettings", defaultValue: "Open Settings")) + alert.addButton(withTitle: String(localized: "dialog.enableNotifications.notNow", defaultValue: "Not Now")) + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { + return + } + self?.openNotificationSettings() + } + } + + static func authorizationState(from status: UNAuthorizationStatus) -> NotificationAuthorizationState { + switch status { + case .authorized: + return .authorized + case .denied: + return .denied + case .notDetermined: + return .notDetermined + case .provisional: + return .provisional + case .ephemeral: + return .ephemeral + @unknown default: + return .unknown + } + } + + static func shouldDeferAutomaticAuthorizationRequest( + status: UNAuthorizationStatus, + isAppActive: Bool + ) -> Bool { + status == .notDetermined && !isAppActive + } + + static func shouldRequestAuthorization( + isAutomaticRequest: Bool, + hasRequestedAutomaticAuthorization: Bool + ) -> Bool { + guard isAutomaticRequest else { return true } + return !hasRequestedAutomaticAuthorization + } + + private static func shouldDeferAutomaticAuthorizationRequest( + origin: AuthorizationRequestOrigin, + status: UNAuthorizationStatus, + isAppActive: Bool + ) -> Bool { + guard origin == .notificationDelivery else { return false } + return shouldDeferAutomaticAuthorizationRequest(status: status, isAppActive: isAppActive) + } + + private static func buildIndexes(for notifications: [TerminalNotification]) -> NotificationIndexes { + var indexes = NotificationIndexes() + for notification in notifications { + if indexes.latestByTabId[notification.tabId] == nil { + indexes.latestByTabId[notification.tabId] = notification + } + guard !notification.isRead else { continue } + indexes.unreadCount += 1 + indexes.unreadCountByTabId[notification.tabId, default: 0] += 1 + indexes.unreadByTabSurface.insert( + TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId) + ) + if indexes.latestUnreadByTabId[notification.tabId] == nil { + indexes.latestUnreadByTabId[notification.tabId] = notification + } + } + return indexes + } + +#if DEBUG + func configureNotificationSettingsPromptHooksForTesting( + windowProvider: @escaping () -> NSWindow?, + alertFactory: @escaping () -> NSAlert, + scheduler: @escaping (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void, + urlOpener: @escaping (URL) -> Void + ) { + notificationSettingsWindowProvider = windowProvider + notificationSettingsAlertFactory = alertFactory + notificationSettingsScheduler = scheduler + notificationSettingsURLOpener = urlOpener + hasPromptedForSettings = false + } + + func resetNotificationSettingsPromptHooksForTesting() { + notificationSettingsWindowProvider = { NSApp.keyWindow ?? NSApp.mainWindow } + notificationSettingsAlertFactory = { NSAlert() } + notificationSettingsScheduler = { delay, block in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + block() + } + } + notificationSettingsURLOpener = { url in + NSWorkspace.shared.open(url) + } + hasPromptedForSettings = false + } + + func configureNotificationDeliveryHandlerForTesting( + _ handler: @escaping (TerminalNotificationStore, TerminalNotification) -> Void + ) { + notificationDeliveryHandler = handler + } + + func resetNotificationDeliveryHandlerForTesting() { + notificationDeliveryHandler = { store, notification in + store.scheduleUserNotification(notification) + } + } + + func promptToEnableNotificationsForTesting() { + promptToEnableNotifications() + } + + func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) { + self.notifications = notifications + } +#endif + private func refreshDockBadge() { let label = Self.dockBadgeLabel( unreadCount: unreadCount, diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 07831c71..b44fbffb 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -17,6 +17,12 @@ private func portalDebugToken(_ view: NSView?) -> String { private func portalDebugFrame(_ rect: NSRect) -> String { String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) } + +private func portalDebugFrameInWindow(_ view: NSView?) -> String { + guard let view else { return "nil" } + guard view.window != nil else { return "no-window" } + return portalDebugFrame(view.convert(view.bounds, to: nil)) +} #endif final class WindowTerminalHostView: NSView { @@ -48,6 +54,13 @@ final class WindowTerminalHostView: NSView { private var lastDragRouteSignature: String? #endif + deinit { + if let trackingArea { + removeTrackingArea(trackingArea) + } + clearActiveDividerCursor(restoreArrow: false) + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window == nil { @@ -116,44 +129,69 @@ final class WindowTerminalHostView: NSView { clearActiveDividerCursor(restoreArrow: true) } + // PERF: hitTest is called on EVERY event including keyboard. Keep non-pointer + // path minimal. Do not add work outside the isPointerEvent guard. override func hitTest(_ point: NSPoint) -> NSView? { - updateDividerCursor(at: point) - - if shouldPassThroughToSidebarResizer(at: point) { - return nil + let currentEvent = NSApp.currentEvent + let isPointerEvent: Bool + switch currentEvent?.type { + case .mouseMoved, .mouseEntered, .mouseExited, + .leftMouseDown, .leftMouseUp, .leftMouseDragged, + .rightMouseDown, .rightMouseUp, .rightMouseDragged, + .otherMouseDown, .otherMouseUp, .otherMouseDragged, + .scrollWheel, .cursorUpdate: + isPointerEvent = true + default: + isPointerEvent = false } - if shouldPassThroughToSplitDivider(at: point) { - return nil - } + if isPointerEvent { + if shouldPassThroughToSidebarResizer(at: point) { + clearActiveDividerCursor(restoreArrow: false) + return nil + } - let dragPasteboardTypes = NSPasteboard(name: .drag).types - let eventType = NSApp.currentEvent?.type - let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting( - pasteboardTypes: dragPasteboardTypes, - eventType: eventType - ) - if shouldPassThrough { + // Compute divider hit once and reuse for both cursor update and pass-through. + if let kind = splitDividerCursorKind(at: point) { + activeDividerCursorKind = kind + kind.cursor.set() + return nil + } + + clearActiveDividerCursor(restoreArrow: true) + + let dragPasteboardTypes = NSPasteboard(name: .drag).types + let eventType = currentEvent?.type + let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting( + pasteboardTypes: dragPasteboardTypes, + eventType: eventType + ) + if shouldPassThrough { +#if DEBUG + logDragRouteDecision( + passThrough: true, + eventType: eventType, + pasteboardTypes: dragPasteboardTypes, + hitView: nil + ) +#endif + return nil + } + + let hitView = super.hitTest(point) #if DEBUG logDragRouteDecision( - passThrough: true, - eventType: eventType, + passThrough: false, + eventType: currentEvent?.type, pasteboardTypes: dragPasteboardTypes, - hitView: nil + hitView: hitView ) #endif - return nil + return hitView === self ? nil : hitView } + // Non-pointer event: skip divider/drag routing, just do standard hit testing. let hitView = super.hitTest(point) -#if DEBUG - logDragRouteDecision( - passThrough: false, - eventType: eventType, - pasteboardTypes: dragPasteboardTypes, - hitView: hitView - ) -#endif return hitView === self ? nil : hitView } @@ -529,6 +567,16 @@ private final class SplitDividerOverlayView: NSView { @MainActor final class WindowTerminalPortal: NSObject { + private static let tinyHideThreshold: CGFloat = 1 + private static let minimumRevealWidth: CGFloat = 24 + private static let minimumRevealHeight: CGFloat = 18 + private static let transientRecoveryRetryBudget: Int = 12 +#if CMUX_ISSUE_483_PORTAL_RECOVERY + private static let transientRecoveryEnabled = true +#else + private static let transientRecoveryEnabled = false +#endif + private weak var window: NSWindow? private let hostView = WindowTerminalHostView(frame: .zero) private let dividerOverlayView = SplitDividerOverlayView(frame: .zero) @@ -536,12 +584,18 @@ final class WindowTerminalPortal: NSObject { private weak var installedReferenceView: NSView? private var installConstraints: [NSLayoutConstraint] = [] private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] +#if DEBUG + private var lastLoggedBonsplitContainerSignature: String? +#endif private struct Entry { weak var hostedView: GhosttySurfaceScrollView? weak var anchorView: NSView? var visibleInUI: Bool var zPriority: Int + var transientRecoveryRetriesRemaining: Int } private var entriesByHostedId: [ObjectIdentifier: Entry] = [:] @@ -550,13 +604,142 @@ final class WindowTerminalPortal: NSObject { init(window: NSWindow) { self.window = window super.init() - hostView.wantsLayer = false + hostView.wantsLayer = true + hostView.layer?.masksToBounds = true + hostView.postsFrameChangedNotifications = true + hostView.postsBoundsChangedNotifications = true hostView.translatesAutoresizingMaskIntoConstraints = false dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true dividerOverlayView.autoresizingMask = [.width, .height] + installGeometryObservers(for: window) _ = ensureInstalled() } + private func installGeometryObservers(for window: NSWindow) { + guard geometryObservers.isEmpty else { return } + + let center = NotificationCenter.default + geometryObservers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + let splitView = notification.object as? NSSplitView, + let window = self.window, + splitView.window === window else { return } + self.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.frameDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.boundsDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + } + + private func removeGeometryObservers() { + for observer in geometryObservers { + NotificationCenter.default.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private func scheduleExternalGeometrySynchronize() { + guard !hasExternalGeometrySyncScheduled else { return } + hasExternalGeometrySyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + } + + private func synchronizeLayoutHierarchy() { + installedContainerView?.layoutSubtreeIfNeeded() + installedReferenceView?.layoutSubtreeIfNeeded() + hostView.superview?.layoutSubtreeIfNeeded() + hostView.layoutSubtreeIfNeeded() + _ = synchronizeHostFrameToReference() + } + + @discardableResult + private func synchronizeHostFrameToReference() -> Bool { + guard let container = installedContainerView, + let reference = installedReferenceView else { + return false + } + let frameInContainer = container.convert(reference.bounds, from: reference) + let hasFiniteFrame = + frameInContainer.origin.x.isFinite && + frameInContainer.origin.y.isFinite && + frameInContainer.size.width.isFinite && + frameInContainer.size.height.isFinite + guard hasFiniteFrame else { return false } + + if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostView.frame = frameInContainer + CATransaction.commit() +#if DEBUG + dlog( + "portal.hostFrame.update host=\(portalDebugToken(hostView)) " + + "frame=\(portalDebugFrame(frameInContainer))" + ) +#endif + } + return frameInContainer.width > 1 && frameInContainer.height > 1 + } + + fileprivate func synchronizeAllEntriesFromExternalGeometryChange() { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() + synchronizeAllHostedViews(excluding: nil) + + // During live resize, AppKit can deliver frame churn where host/container geometry + // settles a tick before the terminal's own scroll/surface hierarchy. Only force an + // in-place surface refresh when reconciliation actually changed terminal geometry. + for entry in entriesByHostedId.values { + guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue } + if hostedView.reconcileGeometryNow() { + hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync") + } + } + } + private func ensureDividerOverlayOnTop() { if dividerOverlayView.superview !== hostView { dividerOverlayView.frame = hostView.bounds @@ -574,7 +757,9 @@ final class WindowTerminalPortal: NSObject { @discardableResult private func ensureInstalled() -> Bool { guard let window else { return false } - guard let (container, reference) = installationTarget(for: window) else { return false } + guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window) + else { return false } + let browserHost = preferredBrowserHost(in: container) if hostView.superview !== container || installedContainerView !== container || @@ -583,7 +768,11 @@ final class WindowTerminalPortal: NSObject { installConstraints.removeAll() hostView.removeFromSuperview() - container.addSubview(hostView, positioned: .above, relativeTo: reference) + if let browserHost { + container.addSubview(hostView, positioned: .below, relativeTo: browserHost) + } else { + container.addSubview(hostView, positioned: .above, relativeTo: reference) + } installConstraints = [ hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor), @@ -594,6 +783,10 @@ final class WindowTerminalPortal: NSObject { NSLayoutConstraint.activate(installConstraints) installedContainerView = container installedReferenceView = reference + } else if let browserHost { + if !Self.isView(browserHost, above: hostView, in: container) { + container.addSubview(hostView, positioned: .below, relativeTo: browserHost) + } } else if !Self.isView(hostView, above: reference, in: container) { container.addSubview(hostView, positioned: .above, relativeTo: reference) } @@ -605,11 +798,29 @@ final class WindowTerminalPortal: NSObject { container.addSubview(overlay, positioned: .above, relativeTo: hostView) } + synchronizeLayoutHierarchy() + _ = synchronizeHostFrameToReference() ensureDividerOverlayOnTop() return true } + private func installedTargetIfStillValid(for window: NSWindow) -> (container: NSView, reference: NSView)? { + guard let container = installedContainerView, + let reference = installedReferenceView else { + return nil + } + + guard hostView.superview === container, + container.window === window, + reference.window === window, + reference.superview === container else { + return nil + } + + return (container, reference) + } + private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { guard let contentView = window.contentView else { return nil } @@ -634,13 +845,32 @@ final class WindowTerminalPortal: NSObject { return false } - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool { abs(lhs.origin.x - rhs.origin.x) <= epsilon && abs(lhs.origin.y - rhs.origin.y) <= epsilon && abs(lhs.size.width - rhs.size.width) <= epsilon && abs(lhs.size.height - rhs.size.height) <= epsilon } + private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { + guard rect.origin.x.isFinite, + rect.origin.y.isFinite, + rect.size.width.isFinite, + rect.size.height.isFinite else { + return rect + } + let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0) + func snap(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + return NSRect( + x: snap(rect.origin.x), + y: snap(rect.origin.y), + width: max(0, snap(rect.size.width)), + height: max(0, snap(rect.size.height)) + ) + } + private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool { guard let viewIndex = container.subviews.firstIndex(of: view), let referenceIndex = container.subviews.firstIndex(of: reference) else { @@ -649,6 +879,91 @@ final class WindowTerminalPortal: NSObject { return viewIndex > referenceIndex } + private func preferredBrowserHost(in container: NSView) -> WindowBrowserHostView? { + container.subviews.last(where: { $0 is WindowBrowserHostView }) as? WindowBrowserHostView + } + +#if DEBUG + private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? { + var current: NSView? = anchorView + while let view = current { + let className = NSStringFromClass(type(of: view)) + if className.contains("PaneDragContainerView") || className.contains("Bonsplit") { + return view + } + current = view.superview + } + return installedReferenceView + } + + private func logBonsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) { + guard let container = nearestBonsplitContainer(from: anchorView) else { return } + let containerFrame = container.convert(container.bounds, to: nil) + let signature = "\(ObjectIdentifier(container)):\(portalDebugFrame(containerFrame))" + guard signature != lastLoggedBonsplitContainerSignature else { return } + lastLoggedBonsplitContainerSignature = signature + + let containerClass = NSStringFromClass(type(of: container)) + dlog( + "portal.bonsplit.container hosted=\(portalDebugToken(hostedView)) " + + "class=\(containerClass) frame=\(portalDebugFrame(containerFrame)) " + + "host=\(portalDebugFrameInWindow(hostView)) anchor=\(portalDebugFrameInWindow(anchorView))" + ) + } +#endif + + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. + /// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when + /// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective + /// visible rect that should drive portal geometry. + private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect { + var frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + var current = anchorView.superview + while let ancestor = current { + let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil) + let finiteAncestorBounds = + ancestorBoundsInWindow.origin.x.isFinite && + ancestorBoundsInWindow.origin.y.isFinite && + ancestorBoundsInWindow.size.width.isFinite && + ancestorBoundsInWindow.size.height.isFinite + if finiteAncestorBounds { + frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow) + if frameInWindow.isNull { return .zero } + } + if ancestor === installedReferenceView { break } + current = ancestor.superview + } + return frameInWindow + } + + private func seededFrameInHost(for anchorView: NSView) -> NSRect? { + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) + let hasFiniteFrame = + frameInHost.origin.x.isFinite && + frameInHost.origin.y.isFinite && + frameInHost.size.width.isFinite && + frameInHost.size.height.isFinite + guard hasFiniteFrame else { return nil } + + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + if hasFiniteHostBounds { + let clampedFrame = frameInHost.intersection(hostBounds) + if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 { + return clampedFrame + } + } + + return frameInHost + } + func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } if let anchor = entry.anchorView { @@ -673,6 +988,7 @@ final class WindowTerminalPortal: NSObject { guard var entry = entriesByHostedId[hostedId] else { return } guard entry.visibleInUI else { return } entry.visibleInUI = false + entry.transientRecoveryRetriesRemaining = 0 entriesByHostedId[hostedId] = entry entry.hostedView?.isHidden = true #if DEBUG @@ -686,9 +1002,18 @@ final class WindowTerminalPortal: NSObject { func updateEntryVisibility(forHostedId hostedId: ObjectIdentifier, visibleInUI: Bool) { guard var entry = entriesByHostedId[hostedId] else { return } entry.visibleInUI = visibleInUI + if !visibleInUI { + entry.transientRecoveryRetriesRemaining = 0 + } entriesByHostedId[hostedId] = entry } + func isHostedViewBoundToAnchor(withId hostedId: ObjectIdentifier, anchorView: NSView) -> Bool { + guard let entry = entriesByHostedId[hostedId], + let boundAnchor = entry.anchorView else { return false } + return boundAnchor === anchorView + } + func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } @@ -720,7 +1045,8 @@ final class WindowTerminalPortal: NSObject { hostedView: hostedView, anchorView: anchorView, visibleInUI: visibleInUI, - zPriority: zPriority + zPriority: zPriority, + transientRecoveryRetriesRemaining: 0 ) let didChangeAnchor: Bool = { @@ -740,6 +1066,32 @@ final class WindowTerminalPortal: NSObject { } #endif + _ = synchronizeHostFrameToReference() + + // Seed frame/bounds before entering the window so a freshly reparented + // surface doesn't do a transient 800x600 size update on viewDidMoveToWindow. + if let seededFrame = seededFrameInHost(for: anchorView), + seededFrame.width > 0, + seededFrame.height > 0 { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = seededFrame + hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size) + CATransaction.commit() + } else { + // If anchor geometry is still unsettled, keep this hidden/zero-sized until + // synchronizeHostedView resolves a valid target frame on the next layout tick. + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = .zero + hostedView.bounds = .zero + CATransaction.commit() + hostedView.isHidden = true + } + // Keep inner scroll/surface geometry in sync with the seeded outer frame + // before the hosted view enters a window. + hostedView.reconcileGeometryNow() + if hostedView.superview !== hostView { #if DEBUG dlog( @@ -765,10 +1117,13 @@ final class WindowTerminalPortal: NSObject { ensureDividerOverlayOnTop() synchronizeHostedView(withId: hostedId) + scheduleDeferredFullSynchronizeAll() pruneDeadEntries() } func synchronizeHostedViewForAnchor(_ anchorView: NSView) { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let anchorId = ObjectIdentifier(anchorView) let primaryHostedId = hostedByAnchorId[anchorId] @@ -795,6 +1150,7 @@ final class WindowTerminalPortal: NSObject { private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) { guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let hostedIds = Array(entriesByHostedId.keys) for hostedId in hostedIds { @@ -803,9 +1159,41 @@ final class WindowTerminalPortal: NSObject { } } + private func resetTransientRecoveryRetryIfNeeded(forHostedId hostedId: ObjectIdentifier, entry: inout Entry) { + guard entry.transientRecoveryRetriesRemaining != 0 else { return } + entry.transientRecoveryRetriesRemaining = 0 + entriesByHostedId[hostedId] = entry + } + + private func scheduleTransientRecoveryRetryIfNeeded( + forHostedId hostedId: ObjectIdentifier, + entry: inout Entry, + hostedView: GhosttySurfaceScrollView, + reason: String + ) -> Bool { + guard Self.transientRecoveryEnabled else { return false } + if entry.transientRecoveryRetriesRemaining == 0 { + entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget + } + guard entry.transientRecoveryRetriesRemaining > 0 else { return false } + + entry.transientRecoveryRetriesRemaining -= 1 + entriesByHostedId[hostedId] = entry +#if DEBUG + dlog( + "portal.sync.deferRecover hosted=\(portalDebugToken(hostedView)) " + + "reason=\(reason) remaining=\(entry.transientRecoveryRetriesRemaining)" + ) +#endif + if entry.transientRecoveryRetriesRemaining > 0 { + scheduleDeferredFullSynchronizeAll() + } + return true + } + private func synchronizeHostedView(withId hostedId: ObjectIdentifier) { guard ensureInstalled() else { return } - guard let entry = entriesByHostedId[hostedId] else { return } + guard var entry = entriesByHostedId[hostedId] else { return } guard let hostedView = entry.hostedView else { entriesByHostedId.removeValue(forKey: hostedId) return @@ -821,6 +1209,14 @@ final class WindowTerminalPortal: NSObject { } #endif hostedView.isHidden = true + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } else { + _ = scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "missingAnchorOrWindow" + ) } return } @@ -833,67 +1229,260 @@ final class WindowTerminalPortal: NSObject { ) } #endif + if entry.visibleInUI { + let shouldPreserveVisibleOnTransient = !hostedView.isHidden && + scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "anchorWindowMismatch" + ) + if shouldPreserveVisibleOnTransient { +#if DEBUG + dlog( + "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + + "reason=anchorWindowMismatch frame=\(portalDebugFrame(hostedView.frame))" + ) +#endif + return + } + } else { + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } hostedView.isHidden = true + if entry.visibleInUI { + _ = scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "anchorWindowMismatch" + ) + } return } - let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) +#if DEBUG + logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView) +#endif + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 + if !hostBoundsReady { +#if DEBUG + dlog( + "portal.sync.defer hosted=\(portalDebugToken(hostedView)) " + + "reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " + + "anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" + ) +#endif + if entry.visibleInUI { + let shouldPreserveVisibleOnTransient = !hostedView.isHidden && + scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "hostBoundsNotReady" + ) + if shouldPreserveVisibleOnTransient { +#if DEBUG + dlog( + "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + + "reason=hostBoundsNotReady frame=\(portalDebugFrame(hostedView.frame))" + ) +#endif + return + } + } else { + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } + hostedView.isHidden = true + if entry.visibleInUI { + if Self.transientRecoveryEnabled { + _ = scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "hostBoundsNotReady" + ) + } else { + scheduleDeferredFullSynchronizeAll() + } + } + return + } let hasFiniteFrame = frameInHost.origin.x.isFinite && frameInHost.origin.y.isFinite && frameInHost.size.width.isFinite && frameInHost.size.height.isFinite + let clampedFrame = frameInHost.intersection(hostBounds) + let hasVisibleIntersection = + !clampedFrame.isNull && + clampedFrame.width > 1 && + clampedFrame.height > 1 + let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) - let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1 - let outsideHostBounds = !frameInHost.intersects(hostView.bounds) + let tinyFrame = + targetFrame.width <= Self.tinyHideThreshold || + targetFrame.height <= Self.tinyHideThreshold + let revealReadyForDisplay = + targetFrame.width >= Self.minimumRevealWidth && + targetFrame.height >= Self.minimumRevealHeight + let outsideHostBounds = !hasVisibleIntersection let shouldHide = !entry.visibleInUI || anchorHidden || tinyFrame || !hasFiniteFrame || outsideHostBounds + let shouldDeferReveal = !shouldHide && hostedView.isHidden && !revealReadyForDisplay + let transientRecoveryReason: String? = { + guard Self.transientRecoveryEnabled else { return nil } + guard entry.visibleInUI else { return nil } + if anchorHidden { return "anchorHidden" } + if !hasFiniteFrame { return "nonFiniteFrame" } + if outsideHostBounds { return "outsideHostBounds" } + if tinyFrame { return "tinyFrame" } + if shouldDeferReveal { return "deferReveal" } + return nil + }() + let didScheduleTransientRecovery: Bool = { + guard let transientRecoveryReason else { return false } + return scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: transientRecoveryReason + ) + }() + let shouldPreserveVisibleOnTransientGeometry = + didScheduleTransientRecovery && + shouldHide && + entry.visibleInUI && + !hostedView.isHidden let oldFrame = hostedView.frame #if DEBUG + let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) + if frameWasClamped { + dlog( + "portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) " + + "raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" + ) + } let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame if collapsedToTiny { dlog( "portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } else if restoredFromTiny { dlog( "portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } #endif - if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + + // Hide before updating the frame when this entry should not be visible. + // This avoids a one-frame flash of unrendered terminal background when a portal + // briefly transitions through offscreen/tiny geometry during rapid split churn. + if shouldHide, !hostedView.isHidden, !shouldPreserveVisibleOnTransientGeometry { +#if DEBUG + dlog( + "portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " + + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" + ) +#endif + hostedView.isHidden = true + } + if shouldPreserveVisibleOnTransientGeometry { +#if DEBUG + dlog( + "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + + "reason=\(transientRecoveryReason ?? "unknown") frame=\(portalDebugFrame(hostedView.frame))" + ) +#endif + } + + if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() CATransaction.setDisableActions(true) - hostedView.frame = frameInHost + hostedView.frame = targetFrame CATransaction.commit() + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow(reason: "portal.frameChange") + } - if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || - abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { - hostedView.reconcileGeometryNow() + if hasFiniteFrame { + let expectedBounds = NSRect(origin: .zero, size: targetFrame.size) + if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.bounds = expectedBounds + CATransaction.commit() } } - if hostedView.isHidden != shouldHide { + if shouldDeferReveal { +#if DEBUG + if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + dlog( + "portal.hidden.deferReveal hosted=\(portalDebugToken(hostedView)) " + + "frame=\(portalDebugFrame(frameInHost)) min=\(Int(Self.minimumRevealWidth))x\(Int(Self.minimumRevealHeight))" + ) + } +#endif + } + + if !shouldHide, hostedView.isHidden, revealReadyForDisplay { #if DEBUG dlog( - "portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " + + "portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + - "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + - "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" ) #endif - hostedView.isHidden = shouldHide + hostedView.isHidden = false + // A reveal can happen without any frame delta (same targetFrame), which means the + // normal frame-change refresh path won't run. Nudge geometry + redraw so newly + // revealed terminals don't sit on a stale/blank IOSurface until later focus churn. + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow(reason: "portal.reveal") } + if transientRecoveryReason == nil { + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } + +#if DEBUG + dlog( + "portal.sync.result hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " + + "hostWin=\(hostView.window?.windowNumber ?? -1) " + + "old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " + + "target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + + "entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " + + "hostBounds=\(portalDebugFrame(hostBounds))" + ) +#endif + ensureDividerOverlayOnTop() } @@ -901,13 +1490,19 @@ final class WindowTerminalPortal: NSObject { let currentWindow = window let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in guard entry.hostedView != nil else { return hostedId } - guard let anchor = entry.anchorView else { return hostedId } - if anchor.window !== currentWindow || anchor.superview == nil { - return hostedId + guard let anchor = entry.anchorView else { + return entry.visibleInUI ? nil : hostedId } - if let reference = installedReferenceView, - !anchor.isDescendant(of: reference) { - return hostedId + + let anchorInvalidForCurrentHost = + anchor.window !== currentWindow || + anchor.superview == nil || + (installedReferenceView.map { !anchor.isDescendant(of: $0) } ?? false) + if anchorInvalidForCurrentHost { + // During aggressive tab drag/reorder churn, SwiftUI/AppKit can briefly + // detach/rehome anchor hosts while the terminal should stay visible. + // Avoid pruning those visible entries so sync/bind recovery can reattach. + return entry.visibleInUI ? nil : hostedId } return nil } @@ -927,6 +1522,7 @@ final class WindowTerminalPortal: NSObject { } func tearDown() { + removeGeometryObservers() for hostedId in Array(entriesByHostedId.keys) { detachHostedView(withId: hostedId) } @@ -938,6 +1534,55 @@ final class WindowTerminalPortal: NSObject { } #if DEBUG + struct DebugStats { + let windowNumber: Int + let entryCount: Int + let hostSubviewCount: Int + let terminalSubviewCount: Int + let mappedTerminalSubviewCount: Int + let orphanTerminalSubviewCount: Int + let visibleOrphanTerminalSubviewCount: Int + let staleEntryCount: Int + } + + func debugStats() -> DebugStats { + let terminalSubviews = hostView.subviews.compactMap { $0 as? GhosttySurfaceScrollView } + var mappedTerminalSubviewCount = 0 + var orphanTerminalSubviewCount = 0 + var visibleOrphanTerminalSubviewCount = 0 + + for hostedView in terminalSubviews { + let hostedId = ObjectIdentifier(hostedView) + if entriesByHostedId[hostedId] != nil { + mappedTerminalSubviewCount += 1 + } else { + orphanTerminalSubviewCount += 1 + if hostedView.window != nil, + !hostedView.isHidden, + hostedView.frame.width > Self.tinyHideThreshold, + hostedView.frame.height > Self.tinyHideThreshold { + visibleOrphanTerminalSubviewCount += 1 + } + } + } + + let staleEntryCount = entriesByHostedId.values.reduce(0) { partialResult, entry in + guard let hostedView = entry.hostedView else { return partialResult + 1 } + return hostedView.superview === hostView ? partialResult : partialResult + 1 + } + + return DebugStats( + windowNumber: window?.windowNumber ?? -1, + entryCount: entriesByHostedId.count, + hostSubviewCount: hostView.subviews.count, + terminalSubviewCount: terminalSubviews.count, + mappedTerminalSubviewCount: mappedTerminalSubviewCount, + orphanTerminalSubviewCount: orphanTerminalSubviewCount, + visibleOrphanTerminalSubviewCount: visibleOrphanTerminalSubviewCount, + staleEntryCount: staleEntryCount + ) + } + func debugEntryCount() -> Int { entriesByHostedId.count } @@ -990,6 +1635,31 @@ final class WindowTerminalPortal: NSObject { enum TerminalWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + private static var hasPendingExternalGeometrySyncForAllWindows = false +#if DEBUG + private static var blockedBindCount: Int = 0 + private static var blockedBindReasons: [String: Int] = [:] +#endif + + private static func bindBlockReason( + expectedSurfaceId: UUID?, + expectedGeneration: UInt64?, + actual: (surfaceId: UUID?, generation: UInt64?, state: String) + ) -> String { + if actual.surfaceId == nil { + return "missingSurface" + } + if actual.state != "live" { + return "state_\(actual.state)" + } + if let expectedSurfaceId, actual.surfaceId != expectedSurfaceId { + return "surfaceMismatch" + } + if let expectedGeneration, actual.generation != expectedGeneration { + return "generationMismatch" + } + return "guardRejected" + } private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return } @@ -1053,11 +1723,46 @@ enum TerminalWindowPortalRegistry { return portal } - static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { + static func bind( + hostedView: GhosttySurfaceScrollView, + to anchorView: NSView, + visibleInUI: Bool, + zPriority: Int = 0, + expectedSurfaceId: UUID? = nil, + expectedGeneration: UInt64? = nil + ) { guard let window = anchorView.window else { return } let windowId = ObjectIdentifier(window) let hostedId = ObjectIdentifier(hostedView) + let guardState = hostedView.portalBindingGuardState() + guard hostedView.canAcceptPortalBinding( + expectedSurfaceId: expectedSurfaceId, + expectedGeneration: expectedGeneration + ) else { + if let oldWindowId = hostedToWindowId.removeValue(forKey: hostedId) { + portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId) + } +#if DEBUG + let reason = bindBlockReason( + expectedSurfaceId: expectedSurfaceId, + expectedGeneration: expectedGeneration, + actual: guardState + ) + blockedBindCount += 1 + blockedBindReasons[reason, default: 0] += 1 + dlog( + "portal.bind.blocked hosted=\(portalDebugToken(hostedView)) " + + "reason=\(reason) expectedSurface=\(expectedSurfaceId?.uuidString.prefix(5) ?? "nil") " + + "expectedGeneration=\(expectedGeneration.map { String($0) } ?? "nil") " + + "actualSurface=\(guardState.surfaceId?.uuidString.prefix(5) ?? "nil") " + + "actualGeneration=\(guardState.generation.map { String($0) } ?? "nil") " + + "actualState=\(guardState.state)" + ) +#endif + return + } + let nextPortal = portal(for: window) if let oldWindowId = hostedToWindowId[hostedId], @@ -1076,6 +1781,17 @@ enum TerminalWindowPortalRegistry { portal.synchronizeHostedViewForAnchor(anchorView) } + static func scheduleExternalGeometrySynchronizeForAllWindows() { + guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } + Self.hasPendingExternalGeometrySyncForAllWindows = true + DispatchQueue.main.async { + Self.hasPendingExternalGeometrySyncForAllWindows = false + for portal in Self.portalsByWindowId.values { + portal.synchronizeAllEntriesFromExternalGeometryChange() + } + } + } + static func hideHostedView(_ hostedView: GhosttySurfaceScrollView) { let hostedId = ObjectIdentifier(hostedView) guard let windowId = hostedToWindowId[hostedId], @@ -1083,6 +1799,14 @@ enum TerminalWindowPortalRegistry { portal.hideEntry(forHostedId: hostedId) } + /// Permanently detach a hosted terminal view from the window-level portal. + /// Use this when a terminal panel is actually closing (not transient SwiftUI dismantle). + static func detach(hostedView: GhosttySurfaceScrollView) { + let hostedId = ObjectIdentifier(hostedView) + guard let windowId = hostedToWindowId.removeValue(forKey: hostedId) else { return } + portalsByWindowId[windowId]?.detachHostedView(withId: hostedId) + } + /// Update the visibleInUI flag on an existing portal entry without rebinding. /// Called when a bind is deferred (host not yet in window) to prevent stale /// portal syncs from hiding a view that is about to become visible. @@ -1093,6 +1817,15 @@ enum TerminalWindowPortalRegistry { portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI) } + static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool { + let hostedId = ObjectIdentifier(hostedView) + guard let window = anchorView.window else { return false } + let windowId = ObjectIdentifier(window) + guard hostedToWindowId[hostedId] == windowId, + let portal = portalsByWindowId[windowId] else { return false } + return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView) + } + static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? { let portal = portal(for: window) return portal.viewAtWindowPoint(windowPoint) @@ -1107,5 +1840,68 @@ enum TerminalWindowPortalRegistry { static func debugPortalCount() -> Int { portalsByWindowId.count } + + static func debugPortalStats() -> [String: Any] { + var portals: [[String: Any]] = [] + var totals: [String: Int] = [ + "entry_count": 0, + "host_subview_count": 0, + "terminal_subview_count": 0, + "mapped_terminal_subview_count": 0, + "orphan_terminal_subview_count": 0, + "visible_orphan_terminal_subview_count": 0, + "stale_entry_count": 0, + "mapped_hosted_count": 0, + ] + + for (windowId, portal) in portalsByWindowId { + let stats = portal.debugStats() + let mappedHostedCount = hostedToWindowId.values.reduce(0) { partialResult, mappedWindowId in + partialResult + (mappedWindowId == windowId ? 1 : 0) + } + let integrityOK = + stats.orphanTerminalSubviewCount == 0 && + stats.visibleOrphanTerminalSubviewCount == 0 && + stats.staleEntryCount == 0 && + mappedHostedCount == stats.entryCount + + portals.append([ + "window_number": stats.windowNumber, + "entry_count": stats.entryCount, + "mapped_hosted_count": mappedHostedCount, + "host_subview_count": stats.hostSubviewCount, + "terminal_subview_count": stats.terminalSubviewCount, + "mapped_terminal_subview_count": stats.mappedTerminalSubviewCount, + "orphan_terminal_subview_count": stats.orphanTerminalSubviewCount, + "visible_orphan_terminal_subview_count": stats.visibleOrphanTerminalSubviewCount, + "stale_entry_count": stats.staleEntryCount, + "integrity_ok": integrityOK, + ]) + + totals["entry_count", default: 0] += stats.entryCount + totals["host_subview_count", default: 0] += stats.hostSubviewCount + totals["terminal_subview_count", default: 0] += stats.terminalSubviewCount + totals["mapped_terminal_subview_count", default: 0] += stats.mappedTerminalSubviewCount + totals["orphan_terminal_subview_count", default: 0] += stats.orphanTerminalSubviewCount + totals["visible_orphan_terminal_subview_count", default: 0] += stats.visibleOrphanTerminalSubviewCount + totals["stale_entry_count", default: 0] += stats.staleEntryCount + totals["mapped_hosted_count", default: 0] += mappedHostedCount + } + + portals.sort { + let lhs = ($0["window_number"] as? Int) ?? Int.min + let rhs = ($1["window_number"] as? Int) ?? Int.min + return lhs < rhs + } + + return [ + "portal_count": portals.count, + "hosted_mapping_count": hostedToWindowId.count, + "guarded_bind_blocked_count": blockedBindCount, + "guarded_bind_blocked_reasons": blockedBindReasons, + "portals": portals, + "totals": totals, + ] + } #endif } diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 0fc1c4e1..94fae950 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -8,6 +8,8 @@ class UpdateController { private(set) var updater: SPUUpdater private let userDriver: UpdateDriver private var installCancellable: AnyCancellable? + private var attemptInstallCancellable: AnyCancellable? + private var didObserveAttemptUpdateProgress: Bool = false private var noUpdateDismissCancellable: AnyCancellable? private var noUpdateDismissWorkItem: DispatchWorkItem? private var readyCheckWorkItem: DispatchWorkItem? @@ -46,6 +48,7 @@ class UpdateController { deinit { installCancellable?.cancel() + attemptInstallCancellable?.cancel() noUpdateDismissCancellable?.cancel() noUpdateDismissWorkItem?.cancel() readyCheckWorkItem?.cancel() @@ -107,6 +110,35 @@ class UpdateController { } } + /// Check for updates and auto-confirm install if one is found. + func attemptUpdate() { + stopAttemptUpdateMonitoring() + didObserveAttemptUpdateProgress = false + + attemptInstallCancellable = viewModel.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self else { return } + + if state.isInstallable || !state.isIdle { + self.didObserveAttemptUpdateProgress = true + } + + if case .updateAvailable = state { + UpdateLogStore.shared.append("attemptUpdate auto-confirming available update") + state.confirm() + return + } + + guard self.didObserveAttemptUpdateProgress, !state.isInstallable else { + return + } + self.stopAttemptUpdateMonitoring() + } + + checkForUpdates() + } + /// Check for updates (used by the menu item). @objc func checkForUpdates() { UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))") @@ -175,6 +207,12 @@ class UpdateController { return true } + private func stopAttemptUpdateMonitoring() { + attemptInstallCancellable?.cancel() + attemptInstallCancellable = nil + didObserveAttemptUpdateProgress = false + } + private func installNoUpdateDismissObserver() { noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState) .receive(on: DispatchQueue.main) diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 32e2304c..dfcd457c 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -80,7 +80,9 @@ extension UpdateDriver: SPUUpdaterDelegate { } } + @MainActor func updaterWillRelaunchApplication(_ updater: SPUUpdater) { + AppDelegate.shared?.persistSessionForUpdateRelaunch() TerminalController.shared.stop() NSApp.invalidateRestorableState() for window in NSApp.windows { diff --git a/Sources/Update/UpdatePill.swift b/Sources/Update/UpdatePill.swift index a976fee5..ed43c192 100644 --- a/Sources/Update/UpdatePill.swift +++ b/Sources/Update/UpdatePill.swift @@ -1,4 +1,5 @@ import AppKit +import Bonsplit import Foundation import SwiftUI @@ -54,7 +55,7 @@ struct UpdatePill: View { .contentShape(Capsule()) } .buttonStyle(.plain) - .help(model.text) + .safeHelp(model.text) .accessibilityLabel(model.text) .accessibilityIdentifier("UpdatePill") } @@ -72,7 +73,7 @@ struct InstallUpdateMenuItem: View { var body: some View { if model.state.isInstallable { - Button("Install Update and Relaunch") { + Button(String(localized: "update.installAndRelaunch", defaultValue: "Install Update and Relaunch")) { model.state.confirm() } } diff --git a/Sources/Update/UpdatePopoverView.swift b/Sources/Update/UpdatePopoverView.swift index 2b1fc3b1..2361775d 100644 --- a/Sources/Update/UpdatePopoverView.swift +++ b/Sources/Update/UpdatePopoverView.swift @@ -49,17 +49,17 @@ fileprivate struct PermissionRequestView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { - Text("Enable automatic updates?") + Text(String(localized: "update.popover.enableAutoUpdates", defaultValue: "Enable automatic updates?")) .font(.system(size: 13, weight: .semibold)) - Text("cmux can automatically check for updates in the background.") + Text(String(localized: "update.popover.autoUpdatesDescription", defaultValue: "cmux can automatically check for updates in the background.")) .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 8) { - Button("Not Now") { + Button(String(localized: "common.notNow", defaultValue: "Not Now")) { request.reply(SUUpdatePermissionResponse( automaticUpdateChecks: false, sendSystemProfile: false)) @@ -69,7 +69,7 @@ fileprivate struct PermissionRequestView: View { Spacer() - Button("Allow") { + Button(String(localized: "common.allow", defaultValue: "Allow")) { request.reply(SUUpdatePermissionResponse( automaticUpdateChecks: true, sendSystemProfile: false)) @@ -92,13 +92,13 @@ fileprivate struct CheckingView: View { HStack(spacing: 10) { ProgressView() .controlSize(.small) - Text("Checking for updates…") + Text(String(localized: "update.popover.checking", defaultValue: "Checking for updates…")) .font(.system(size: 13)) } HStack { Spacer() - Button("Cancel") { + Button(String(localized: "common.cancel", defaultValue: "Cancel")) { checking.cancel() dismiss() } @@ -120,12 +120,12 @@ fileprivate struct UpdateAvailableView: View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) { - Text("Update Available") + Text(String(localized: "update.popover.updateAvailable", defaultValue: "Update Available")) .font(.system(size: 13, weight: .semibold)) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { - Text("Version:") + Text(String(localized: "update.popover.version", defaultValue: "Version:")) .foregroundColor(.secondary) .frame(width: labelWidth, alignment: .trailing) Text(update.appcastItem.displayVersionString) @@ -134,7 +134,7 @@ fileprivate struct UpdateAvailableView: View { if update.appcastItem.contentLength > 0 { HStack(spacing: 6) { - Text("Size:") + Text(String(localized: "update.popover.size", defaultValue: "Size:")) .foregroundColor(.secondary) .frame(width: labelWidth, alignment: .trailing) Text(ByteCountFormatter.string(fromByteCount: Int64(update.appcastItem.contentLength), countStyle: .file)) @@ -144,7 +144,7 @@ fileprivate struct UpdateAvailableView: View { if let date = update.appcastItem.date { HStack(spacing: 6) { - Text("Released:") + Text(String(localized: "update.popover.released", defaultValue: "Released:")) .foregroundColor(.secondary) .frame(width: labelWidth, alignment: .trailing) Text(date.formatted(date: .abbreviated, time: .omitted)) @@ -156,13 +156,13 @@ fileprivate struct UpdateAvailableView: View { } HStack(spacing: 8) { - Button("Skip") { + Button(String(localized: "common.skip", defaultValue: "Skip")) { update.reply(.skip) dismiss() } .controlSize(.small) - Button("Later") { + Button(String(localized: "common.later", defaultValue: "Later")) { update.reply(.dismiss) dismiss() } @@ -171,7 +171,7 @@ fileprivate struct UpdateAvailableView: View { Spacer() - Button("Install and Relaunch") { + Button(String(localized: "common.installAndRelaunch", defaultValue: "Install and Relaunch")) { update.reply(.install) dismiss() } @@ -214,7 +214,7 @@ fileprivate struct DownloadingView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { - Text("Downloading Update") + Text(String(localized: "update.popover.downloadingUpdate", defaultValue: "Downloading Update")) .font(.system(size: 13, weight: .semibold)) if let expectedLength = download.expectedLength, expectedLength > 0 { @@ -233,7 +233,7 @@ fileprivate struct DownloadingView: View { HStack { Spacer() - Button("Cancel") { + Button(String(localized: "common.cancel", defaultValue: "Cancel")) { download.cancel() dismiss() } @@ -250,7 +250,7 @@ fileprivate struct ExtractingView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text("Preparing Update") + Text(String(localized: "update.popover.preparingUpdate", defaultValue: "Preparing Update")) .font(.system(size: 13, weight: .semibold)) VStack(alignment: .leading, spacing: 6) { @@ -271,17 +271,17 @@ fileprivate struct InstallingView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { - Text("Restart Required") + Text(String(localized: "update.popover.restartRequired", defaultValue: "Restart Required")) .font(.system(size: 13, weight: .semibold)) - Text("The update is ready. Please restart the application to complete the installation.") + Text(String(localized: "update.popover.restartRequired.message", defaultValue: "The update is ready. Please restart the application to complete the installation.")) .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack { - Button("Restart Later") { + Button(String(localized: "common.restartLater", defaultValue: "Restart Later")) { installing.dismiss() dismiss() } @@ -290,7 +290,7 @@ fileprivate struct InstallingView: View { Spacer() - Button("Restart Now") { + Button(String(localized: "common.restartNow", defaultValue: "Restart Now")) { installing.retryTerminatingApplication() dismiss() } @@ -310,10 +310,10 @@ fileprivate struct NotFoundView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { - Text("No Updates Found") + Text(String(localized: "update.popover.noUpdatesFound", defaultValue: "No Updates Found")) .font(.system(size: 13, weight: .semibold)) - Text("You're already running the latest version.") + Text(String(localized: "update.popover.noUpdatesFound.message", defaultValue: "You're already running the latest version.")) .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -321,7 +321,7 @@ fileprivate struct NotFoundView: View { HStack { Spacer() - Button("OK") { + Button(String(localized: "common.ok", defaultValue: "OK")) { notFound.acknowledgement() dismiss() } @@ -363,7 +363,7 @@ fileprivate struct UpdateErrorView: View { } VStack(alignment: .leading, spacing: 6) { - Text("Details") + Text(String(localized: "update.popover.details", defaultValue: "Details")) .font(.system(size: 11, weight: .semibold)) Text(details) .font(.system(size: 10, design: .monospaced)) @@ -373,14 +373,14 @@ fileprivate struct UpdateErrorView: View { } HStack(spacing: 8) { - Button("Copy Details") { + Button(String(localized: "common.copyDetails", defaultValue: "Copy Details")) { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(details, forType: .string) } .controlSize(.small) - Button("OK") { + Button(String(localized: "common.ok", defaultValue: "OK")) { error.dismiss() dismiss() } @@ -389,7 +389,7 @@ fileprivate struct UpdateErrorView: View { Spacer() - Button("Retry") { + Button(String(localized: "common.retry", defaultValue: "Retry")) { error.retry() dismiss() } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index ff73c91a..984df39c 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -193,6 +193,14 @@ struct ShortcutHintHorizontalPlanner { } } +func titlebarShortcutHintHeight(for config: TitlebarControlsStyleConfig) -> CGFloat { + max(14, config.iconSize + 1) +} + +func titlebarShortcutHintVerticalOffset(for config: TitlebarControlsStyleConfig) -> CGFloat { + max(0, floor(config.buttonSize - titlebarShortcutHintHeight(for: config))) +} + struct TitlebarControlButton<Content: View>: View { let config: TitlebarControlsStyleConfig let action: () -> Void @@ -200,7 +208,7 @@ struct TitlebarControlButton<Content: View>: View { @State private var isHovering = false var body: some View { - Button(action: action) { + let baseButton = Button(action: action) { content() .frame(width: config.buttonSize, height: config.buttonSize) .contentShape(Rectangle()) @@ -209,7 +217,12 @@ struct TitlebarControlButton<Content: View>: View { .frame(width: config.buttonSize, height: config.buttonSize) .contentShape(Rectangle()) .background(hoverBackground) - .onHover { isHovering = $0 } + + if titlebarControlsShouldTrackButtonHover(config: config) { + baseButton.onHover { isHovering = $0 } + } else { + baseButton + } } @ViewBuilder @@ -232,10 +245,9 @@ struct TitlebarControlsView: View { @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @State private var shortcutRefreshTick = 0 - @StateObject private var commandKeyMonitor = TitlebarCommandKeyMonitor() + @StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor() private let titlebarHintRightSafetyShift: CGFloat = 10 private let titlebarHintBaseXShift: CGFloat = -10 - private let titlebarHintBaseYShift: CGFloat = 1 private enum HintSlot: Int, CaseIterable { case toggleSidebar @@ -264,11 +276,11 @@ struct TitlebarControlsView: View { } private var shouldShowTitlebarShortcutHints: Bool { - alwaysShowShortcutHints || commandKeyMonitor.isCommandPressed + alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed } var body: some View { - // Force the `.help(...)` tooltips to re-evaluate when shortcuts are changed in settings. + // Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings. // (The titlebar controls don't otherwise re-render on UserDefaults changes.) let _ = shortcutRefreshTick let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic @@ -278,7 +290,7 @@ struct TitlebarControlsView: View { .padding(.trailing, titlebarHintTrailingInset) .background( WindowAccessor { window in - commandKeyMonitor.setHostWindow(window) + modifierKeyMonitor.setHostWindow(window) } .frame(width: 0, height: 0) ) @@ -286,10 +298,10 @@ struct TitlebarControlsView: View { shortcutRefreshTick &+= 1 } .onAppear { - commandKeyMonitor.start() + modifierKeyMonitor.start() } .onDisappear { - commandKeyMonitor.stop() + modifierKeyMonitor.stop() } } @@ -299,7 +311,7 @@ struct TitlebarControlsView: View { } private func titlebarHintVerticalBaseOffset(for config: TitlebarControlsStyleConfig) -> CGFloat { - max(8, config.buttonSize * 0.4) + titlebarShortcutHintVerticalOffset(for: config) } @ViewBuilder @@ -315,8 +327,8 @@ struct TitlebarControlsView: View { iconLabel(systemName: "sidebar.left", config: config) } .accessibilityIdentifier("titlebarControl.toggleSidebar") - .accessibilityLabel("Toggle Sidebar") - .help(KeyboardShortcutSettings.Action.toggleSidebar.tooltip("Show or hide the sidebar")) + .accessibilityLabel(String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar")) + .safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar"))) TitlebarControlButton(config: config, action: { #if DEBUG @@ -333,7 +345,7 @@ struct TitlebarControlsView: View { .foregroundColor(.white) .frame(width: config.badgeSize, height: config.badgeSize) .background( - Circle().fill(Color.accentColor) + Circle().fill(cmuxAccentColor()) ) .offset(x: config.badgeOffset.width, y: config.badgeOffset.height) } @@ -341,9 +353,9 @@ struct TitlebarControlsView: View { .frame(width: config.buttonSize, height: config.buttonSize) } .accessibilityIdentifier("titlebarControl.showNotifications") - .overlay(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }.allowsHitTesting(false)) - .accessibilityLabel("Notifications") - .help(KeyboardShortcutSettings.Action.showNotifications.tooltip("Show notifications")) + .background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }) + .accessibilityLabel(String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications")) + .safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications"))) TitlebarControlButton(config: config, action: { #if DEBUG @@ -354,8 +366,8 @@ struct TitlebarControlsView: View { iconLabel(systemName: "plus", config: config) } .accessibilityIdentifier("titlebarControl.newTab") - .accessibilityLabel("New Workspace") - .help(KeyboardShortcutSettings.Action.newTab.tooltip("New workspace")) + .accessibilityLabel(String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace")) + .safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace"))) } let paddedContent = content.padding(config.groupPadding) @@ -447,7 +459,6 @@ struct TitlebarControlsView: View { ) -> some View { let yOffset = config.groupPadding.top + titlebarHintVerticalBaseOffset(for: config) - + titlebarHintBaseYShift + ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset) ZStack(alignment: .topLeading) { @@ -475,7 +486,7 @@ struct TitlebarControlsView: View { .foregroundColor(.primary) .padding(.horizontal, 6) .padding(.vertical, 2) - .frame(minHeight: max(14, config.iconSize + 1)) + .frame(minHeight: titlebarShortcutHintHeight(for: config)) .background(ShortcutHintPillBackground()) } @@ -498,8 +509,8 @@ struct TitlebarControlsView: View { } @MainActor -private final class TitlebarCommandKeyMonitor: ObservableObject { - @Published private(set) var isCommandPressed = false +private final class TitlebarShortcutHintModifierMonitor: ObservableObject { + @Published private(set) var isModifierPressed = false private weak var hostWindow: NSWindow? private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? @@ -593,7 +604,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, eventWindowNumber: eventWindow?.windowNumber, @@ -602,7 +613,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: modifierFlags, hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, @@ -617,31 +628,31 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } private func queueHintShow() { - guard !isCommandPressed else { return } + guard !isModifierPressed else { return } guard pendingShowWorkItem == nil else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: NSEvent.modifierFlags, hostWindowNumber: self.hostWindow?.windowNumber, hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, eventWindowNumber: nil, keyWindowNumber: NSApp.keyWindow?.windowNumber ) else { return } - self.isCommandPressed = true + self.isModifierPressed = true } pendingShowWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem) } private func cancelPendingHintShow(resetVisible: Bool) { pendingShowWorkItem?.cancel() pendingShowWorkItem = nil if resetVisible { - isCommandPressed = false + isModifierPressed = false } } @@ -657,12 +668,49 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } } +struct TitlebarControlsLayoutSnapshot: Equatable { + let contentSize: NSSize + let containerHeight: CGFloat + let yOffset: CGFloat +} + +func titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyleConfig) -> Bool { + config.hoverBackground +} + +func titlebarControlsShouldScheduleForViewSizeChange( + previous: NSSize, + current: NSSize, + tolerance: CGFloat = 0.5 +) -> Bool { + guard current.width > 0, current.height > 0 else { return false } + guard previous.width > 0, previous.height > 0 else { return true } + return abs(previous.width - current.width) > tolerance + || abs(previous.height - current.height) > tolerance +} + +func titlebarControlsShouldApplyLayout( + previous: TitlebarControlsLayoutSnapshot?, + next: TitlebarControlsLayoutSnapshot, + tolerance: CGFloat = 0.5 +) -> Bool { + guard let previous else { return true } + return abs(previous.contentSize.width - next.contentSize.width) > tolerance + || abs(previous.contentSize.height - next.contentSize.height) > tolerance + || abs(previous.containerHeight - next.containerHeight) > tolerance + || abs(previous.yOffset - next.yOffset) > tolerance +} + final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate { private let hostingView: NonDraggableHostingView<TitlebarControlsView> private let containerView = NSView() private let notificationStore: TerminalNotificationStore private lazy var notificationsPopover: NSPopover = makeNotificationsPopover() private var pendingSizeUpdate = false + private var fittingSizeNeedsRefresh = true + private var cachedFittingSize: NSSize? + private var lastObservedViewSize: NSSize = .zero + private var lastAppliedLayoutSnapshot: TitlebarControlsLayoutSnapshot? private let viewModel = TitlebarControlsViewModel() private var userDefaultsObserver: NSObjectProtocol? var popoverIsShownForTesting: Bool { notificationsPopover.isShown } @@ -687,6 +735,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont view = containerView containerView.translatesAutoresizingMaskIntoConstraints = true + // Prevent the titlebar accessory from clipping button backgrounds + // at the bottom edge (the system constrains accessory height to the + // titlebar, which can be slightly shorter than the button frames). + containerView.wantsLayer = true + containerView.layer?.masksToBounds = false hostingView.translatesAutoresizingMaskIntoConstraints = true hostingView.autoresizingMask = [.width, .height] containerView.addSubview(hostingView) @@ -696,10 +749,10 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont object: nil, queue: .main ) { [weak self] _ in - self?.scheduleSizeUpdate() + self?.scheduleSizeUpdate(invalidateFittingSize: true) } - scheduleSizeUpdate() + scheduleSizeUpdate(invalidateFittingSize: true) } required init?(coder: NSCoder) { @@ -714,15 +767,26 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont override func viewDidAppear() { super.viewDidAppear() - scheduleSizeUpdate() + scheduleSizeUpdate(invalidateFittingSize: true) } override func viewDidLayout() { super.viewDidLayout() - scheduleSizeUpdate() + let currentViewSize = view.bounds.size + guard titlebarControlsShouldScheduleForViewSizeChange( + previous: lastObservedViewSize, + current: currentViewSize + ) else { + return + } + lastObservedViewSize = currentViewSize + scheduleSizeUpdate(invalidateFittingSize: true) } - private func scheduleSizeUpdate() { + private func scheduleSizeUpdate(invalidateFittingSize: Bool = false) { + if invalidateFittingSize { + fittingSizeNeedsRefresh = true + } guard !pendingSizeUpdate else { return } pendingSizeUpdate = true DispatchQueue.main.async { [weak self] in @@ -732,14 +796,33 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont } private func updateSize() { - hostingView.invalidateIntrinsicContentSize() - hostingView.layoutSubtreeIfNeeded() - let contentSize = hostingView.fittingSize + let contentSize: NSSize + if fittingSizeNeedsRefresh || cachedFittingSize == nil { + hostingView.invalidateIntrinsicContentSize() + hostingView.layoutSubtreeIfNeeded() + cachedFittingSize = hostingView.fittingSize + fittingSizeNeedsRefresh = false + } + contentSize = cachedFittingSize ?? .zero + + guard contentSize.width > 0, contentSize.height > 0 else { return } let titlebarHeight = view.window.map { window in window.frame.height - window.contentLayoutRect.height } ?? contentSize.height let containerHeight = max(contentSize.height, titlebarHeight) let yOffset = max(0, (containerHeight - contentSize.height) / 2.0) + let nextLayoutSnapshot = TitlebarControlsLayoutSnapshot( + contentSize: contentSize, + containerHeight: containerHeight, + yOffset: yOffset + ) + guard titlebarControlsShouldApplyLayout( + previous: lastAppliedLayoutSnapshot, + next: nextLayoutSnapshot + ) else { + return + } + lastAppliedLayoutSnapshot = nextLayoutSnapshot preferredContentSize = NSSize(width: contentSize.width, height: containerHeight) containerView.frame = NSRect(x: 0, y: 0, width: contentSize.width, height: containerHeight) hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height) @@ -824,20 +907,37 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private struct NotificationsPopoverView: View { @ObservedObject var notificationStore: TerminalNotificationStore + @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() let onDismiss: () -> Void var body: some View { VStack(spacing: 0) { HStack { - Text("Notifications") + Text(String(localized: "notifications.title", defaultValue: "Notifications")) .font(.headline) Spacer() - if !notificationStore.notifications.isEmpty { - Button("Clear All") { - notificationStore.clearAll() + Button(action: jumpToLatestUnread) { + HStack(spacing: 6) { + Text(String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest")) + Text(jumpToUnreadShortcut.displayString) } - .buttonStyle(.bordered) } + .buttonStyle(.bordered) + .accessibilityIdentifier("notificationsPopover.jumpToLatest") + .accessibilityValue(jumpToUnreadShortcut.displayString) + .safeHelp( + KeyboardShortcutSettings.Action.jumpToUnread.tooltip( + String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest") + ) + ) + .disabled(!hasUnreadNotifications) + + Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) { + notificationStore.clearAll() + } + .buttonStyle(.bordered) + .accessibilityIdentifier("notificationsPopover.clearAll") + .disabled(notificationStore.notifications.isEmpty) } .padding(.horizontal, 12) .padding(.vertical, 10) @@ -849,9 +949,9 @@ private struct NotificationsPopoverView: View { Image(systemName: "bell.slash") .font(.system(size: 28)) .foregroundColor(.secondary) - Text("No notifications yet") + Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet")) .font(.headline) - Text("Desktop notifications will appear here.") + Text(String(localized: "notifications.empty.subtitle", defaultValue: "Desktop notifications will appear here.")) .font(.subheadline) .foregroundColor(.secondary) } @@ -880,6 +980,32 @@ private struct NotificationsPopoverView: View { AppDelegate.shared?.tabTitle(for: tabId) } + 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 jumpToLatestUnread() { + DispatchQueue.main.async { + AppDelegate.shared?.jumpToLatestUnread() + onDismiss() + } + } + private func open(_ notification: TerminalNotification) { // SwiftUI action closures are not guaranteed to run on the main actor. // Ensure window focus + tab selection happens on the main thread. @@ -905,11 +1031,11 @@ private struct NotificationPopoverRow: View { Button(action: onOpen) { HStack(alignment: .top, spacing: 10) { Circle() - .fill(notification.isRead ? Color.clear : Color.accentColor) + .fill(notification.isRead ? Color.clear : cmuxAccentColor()) .frame(width: 8, height: 8) .overlay( Circle() - .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + .stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) ) .padding(.top, 6) diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 8aa275af..4bdb9ad2 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -22,27 +22,29 @@ class UpdateViewModel: ObservableObject { case .idle: return "" case .permissionRequest: - return "Enable Automatic Updates?" + return String(localized: "update.permissionRequest.text", defaultValue: "Enable Automatic Updates?") case .checking: - return "Checking for Updates…" + return String(localized: "update.checking", defaultValue: "Checking for Updates…") case .updateAvailable(let update): let version = update.appcastItem.displayVersionString if !version.isEmpty { - return "Update Available: \(version)" + return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(version)") } - return "Update Available" + return String(localized: "update.available.short", defaultValue: "Update Available") case .downloading(let download): if let expectedLength = download.expectedLength, expectedLength > 0 { let progress = Double(download.progress) / Double(expectedLength) - return String(format: "Downloading: %.0f%%", progress * 100) + let percent = String(format: "%.0f%%", progress * 100) + return String(localized: "update.downloading.progress", defaultValue: "Downloading: \(percent)") } - return "Downloading…" + return String(localized: "update.downloading.status", defaultValue: "Downloading…") case .extracting(let extracting): - return String(format: "Preparing: %.0f%%", extracting.progress * 100) + let percent = String(format: "%.0f%%", extracting.progress * 100) + return String(localized: "update.extracting.progress", defaultValue: "Preparing: \(percent)") case .installing(let install): - return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…" + return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installing.status", defaultValue: "Installing…") case .notFound: - return "No Updates Available" + return String(localized: "update.noUpdates.title", defaultValue: "No Updates Available") case .error(let err): return Self.userFacingErrorTitle(for: err.error) } @@ -87,19 +89,19 @@ class UpdateViewModel: ObservableObject { case .idle: return "" case .permissionRequest: - return "Configure automatic update preferences" + return String(localized: "update.configureAutoUpdates", defaultValue: "Configure automatic update preferences") case .checking: - return "Please wait while we check for available updates" + return String(localized: "update.pleaseWait", defaultValue: "Please wait while we check for available updates") case .updateAvailable(let update): - return update.releaseNotes?.label ?? "Download and install the latest version" + return update.releaseNotes?.label ?? String(localized: "update.downloadAndInstall", defaultValue: "Download and install the latest version") case .downloading: - return "Downloading the update package" + return String(localized: "update.downloadingPackage", defaultValue: "Downloading the update package") case .extracting: - return "Extracting and preparing the update" + return String(localized: "update.preparingUpdate", defaultValue: "Extracting and preparing the update") case let .installing(install): - return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart" + return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installingAndRestarting", defaultValue: "Installing update and preparing to restart") case .notFound: - return "You are running the latest version" + return String(localized: "update.noUpdates.message", defaultValue: "You are running the latest version") case .error(let err): return Self.userFacingErrorMessage(for: err.error) } @@ -132,7 +134,7 @@ class UpdateViewModel: ObservableObject { case .checking: return .secondary case .updateAvailable: - return .accentColor + return cmuxAccentColor() case .downloading, .extracting, .installing: return .secondary case .notFound: @@ -147,7 +149,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: @@ -177,21 +179,21 @@ class UpdateViewModel: ObservableObject { if let networkError = networkError(from: nsError) { switch networkError.code { case NSURLErrorNotConnectedToInternet: - return "No Internet Connection" + return String(localized: "update.error.noInternet.title", defaultValue: "No Internet Connection") case NSURLErrorTimedOut: - return "Update Timed Out" + return String(localized: "update.error.timedOut.title", defaultValue: "Update Timed Out") case NSURLErrorCannotFindHost: - return "Server Not Found" + return String(localized: "update.error.serverNotFound.title", defaultValue: "Server Not Found") case NSURLErrorCannotConnectToHost: - return "Server Unreachable" + return String(localized: "update.error.serverUnreachable.title", defaultValue: "Server Unreachable") case NSURLErrorNetworkConnectionLost: - return "Connection Lost" + return String(localized: "update.error.connectionLost.title", defaultValue: "Connection Lost") case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasBadDate, NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateNotYetValid: - return "Secure Connection Failed" + return String(localized: "update.error.secureConnectionFailed.title", defaultValue: "Secure Connection Failed") default: break } @@ -199,24 +201,24 @@ class UpdateViewModel: ObservableObject { if nsError.domain == SUSparkleErrorDomain { switch nsError.code { case 4005: - return "Updater Permission Error" + return String(localized: "update.error.permissionError.title", defaultValue: "Updater Permission Error") case 2001: - return "Couldn't Download Update" + return String(localized: "update.error.downloadFailed.title", defaultValue: "Couldn't Download Update") case 1000, 1002: - return "Update Feed Error" + return String(localized: "update.error.feedError.title", defaultValue: "Update Feed Error") case 4: - return "Invalid Update Feed" + return String(localized: "update.error.invalidFeed.title", defaultValue: "Invalid Update Feed") case 3: - return "Insecure Update Feed" + return String(localized: "update.error.insecureFeed.title", defaultValue: "Insecure Update Feed") case 1, 2, 3001, 3002: - return "Update Signature Error" + return String(localized: "update.error.signatureError.title", defaultValue: "Update Signature Error") case 1003, 1005: - return "App Location Issue" + return String(localized: "update.error.appLocation.title", defaultValue: "App Location Issue") default: break } } - return "Update Failed" + return String(localized: "update.error.failed.title", defaultValue: "Update Failed") } static func userFacingErrorMessage(for error: Swift.Error) -> String { @@ -224,21 +226,21 @@ class UpdateViewModel: ObservableObject { if let networkError = networkError(from: nsError) { switch networkError.code { case NSURLErrorNotConnectedToInternet: - return "cmux can’t reach the update server. Check your internet connection and try again." + return String(localized: "update.error.noInternet.message", defaultValue: "cmux can’t reach the update server. Check your internet connection and try again.") case NSURLErrorTimedOut: - return "The update server took too long to respond. Try again in a moment." + return String(localized: "update.error.timedOut.message", defaultValue: "The update server took too long to respond. Try again in a moment.") case NSURLErrorCannotFindHost: - return "The update server can’t be found. Check your connection or try again later." + return String(localized: "update.error.serverNotFound.message", defaultValue: "The update server can’t be found. Check your connection or try again later.") case NSURLErrorCannotConnectToHost: - return "cmux couldn’t connect to the update server. Check your connection or try again later." + return String(localized: "update.error.serverUnreachable.message", defaultValue: "cmux couldn’t connect to the update server. Check your connection or try again later.") case NSURLErrorNetworkConnectionLost: - return "The network connection was lost while checking for updates. Try again." + return String(localized: "update.error.connectionLost.message", defaultValue: "The network connection was lost while checking for updates. Try again.") case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasBadDate, NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateNotYetValid: - return "A secure connection to the update server couldn’t be established. Try again later." + return String(localized: "update.error.secureConnectionFailed.message", defaultValue: "A secure connection to the update server couldn’t be established. Try again later.") default: break } @@ -246,17 +248,17 @@ class UpdateViewModel: ObservableObject { if nsError.domain == SUSparkleErrorDomain { switch nsError.code { case 2001: - return "cmux couldn't download the update feed. Check your connection and try again." + return String(localized: "update.error.feedDownload.message", defaultValue: "cmux couldn't download the update feed. Check your connection and try again.") case 1000, 1002: - return "The update feed could not be read. Please try again later." + return String(localized: "update.error.feedRead.message", defaultValue: "The update feed could not be read. Please try again later.") case 4: - return "The update feed URL is invalid. Please contact support." + return String(localized: "update.error.invalidFeed.message", defaultValue: "The update feed URL is invalid. Please contact support.") case 3: - return "The update feed is insecure. Please contact support." + return String(localized: "update.error.insecureFeed.message", defaultValue: "The update feed is insecure. Please contact support.") case 1, 2, 3001, 3002: - return "The update's signature could not be verified. Please try again later." + return String(localized: "update.error.signatureError.message", defaultValue: "The update's signature could not be verified. Please try again later.") case 1003, 1005, 4005: - return "Move cmux into Applications and relaunch to enable updates." + return String(localized: "update.error.permissionError.message", defaultValue: "Move cmux into Applications and relaunch to enable updates.") default: break } @@ -487,8 +489,8 @@ enum UpdateState: Equatable { var label: String { switch self { - case .commit: return "View GitHub Commit" - case .tagged: return "View Release Notes" + case .commit: return String(localized: "update.viewGitHubCommit", defaultValue: "View GitHub Commit") + case .tagged: return String(localized: "update.viewReleaseNotes", defaultValue: "View Release Notes") } } } diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index e534e1bc..3aa5f16d 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -1,6 +1,367 @@ import AppKit +import Bonsplit import SwiftUI +private func windowDragHandleFormatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) +} + +private func windowDragHandleEventTypeDescription(_ eventType: NSEvent.EventType?) -> String { + eventType.map { String(describing: $0) } ?? "nil" +} + +private enum WindowDragHandleBreadcrumbLimiter { + private static let lock = NSLock() + private static var lastEmissionByKey: [String: CFAbsoluteTime] = [:] + + static func shouldEmit(key: String, minInterval: CFTimeInterval) -> Bool { + lock.lock() + defer { lock.unlock() } + + let now = CFAbsoluteTimeGetCurrent() + if let previous = lastEmissionByKey[key], (now - previous) < minInterval { + return false + } + lastEmissionByKey[key] = now + if lastEmissionByKey.count > 128 { + let staleThreshold = now - max(minInterval * 4, 60) + lastEmissionByKey = lastEmissionByKey.filter { _, timestamp in + timestamp >= staleThreshold + } + } + return true + } +} + +private func windowDragHandleEmitBreadcrumb( + _ message: String, + window: NSWindow?, + eventType: NSEvent.EventType?, + point: NSPoint, + minInterval: CFTimeInterval = 10, + extraData: [String: Any] = [:] +) { + let windowNumber = window?.windowNumber ?? -1 + let key = "\(message):\(windowNumber)" + guard WindowDragHandleBreadcrumbLimiter.shouldEmit(key: key, minInterval: minInterval) else { + return + } + + var data: [String: Any] = [ + "event_type": windowDragHandleEventTypeDescription(eventType), + "point": windowDragHandleFormatPoint(point), + "window_number": windowNumber, + "window_present": window != nil + ] + for (name, value) in extraData { + data[name] = value + } + sentryBreadcrumb(message, category: "titlebar.drag", data: data) +} + +private func windowDragHandleShouldResolveActiveHitCapture( + for eventType: NSEvent.EventType?, + eventWindow: NSWindow?, + dragHandleWindow: NSWindow? +) -> Bool { + // We only need active hit resolution for titlebar mouse-down handling. + // During launch, NSApp.currentEvent can transiently point at a stale + // leftMouseDown from outside this window (for example Finder/Dock + // activation). Treat those as passive events so we never walk SwiftUI/ + // AppKit hierarchy while initial layout is mutating it. + guard eventType == .leftMouseDown else { + return false + } + guard let dragHandleWindow else { + // Test-only views may not be attached to a window. + return true + } + guard let eventWindow else { + return false + } + return eventWindow === dragHandleWindow +} + +/// Runs the same action macOS titlebars use for double-click: +/// zoom by default, or minimize when the user preference is set. +@discardableResult +func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool { + guard let window else { return false } + + let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] + if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() { + switch action { + case "minimize": + window.miniaturize(nil) + return true + case "none": + return false + case "maximize", "zoom": + window.zoom(nil) + return true + default: + break + } + } + + if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool, + miniaturizeOnDoubleClick { + window.miniaturize(nil) + return true + } + + window.zoom(nil) + return true +} + +private enum WindowDragHandleAssociatedObjectKeys { + private static let suppressionDepthToken = NSObject() + + static let suppressionDepth = UnsafeRawPointer(Unmanaged.passUnretained(suppressionDepthToken).toOpaque()) +} + +func beginWindowDragSuppression(window: NSWindow?) -> Int? { + guard let window else { return nil } + let current = windowDragSuppressionDepth(window: window) + let next = current + 1 + objc_setAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.suppressionDepth, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return next +} + +@discardableResult +func endWindowDragSuppression(window: NSWindow?) -> Int { + guard let window else { return 0 } + let current = windowDragSuppressionDepth(window: window) + let next = max(0, current - 1) + if next == 0 { + objc_setAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.suppressionDepth, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } else { + objc_setAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.suppressionDepth, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + return next +} + +func windowDragSuppressionDepth(window: NSWindow?) -> Int { + guard let window, + let value = objc_getAssociatedObject(window, WindowDragHandleAssociatedObjectKeys.suppressionDepth) as? NSNumber else { + return 0 + } + return value.intValue +} + +func isWindowDragSuppressed(window: NSWindow?) -> Bool { + windowDragSuppressionDepth(window: window) > 0 +} + +@discardableResult +func clearWindowDragSuppression(window: NSWindow?) -> Int { + guard let window else { return 0 } + var depth = windowDragSuppressionDepth(window: window) + while depth > 0 { + depth = endWindowDragSuppression(window: window) + } + return depth +} + +/// Temporarily enables window movability for explicit drag-handle drags, then +/// restores the previous movability state after `body` finishes. +@discardableResult +func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? { + guard let window else { + body() + return nil + } + + let previousMovableState = window.isMovable + if !previousMovableState { + window.isMovable = true + } + defer { + if window.isMovable != previousMovableState { + window.isMovable = previousMovableState + } + } + + body() + return previousMovableState +} + +/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty +/// titlebar space. Treat those as pass-through so explicit sibling checks decide. +func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool { + let className = String(describing: type(of: view)) + if className.contains("HostContainerView") + || className.contains("AppKitWindowHostingView") + || className.contains("NSHostingView") { + return true + } + if let window = view.window, view === window.contentView { + return true + } + return false +} + +/// Re-entrancy guard for the sibling hit-test walk. When `sibling.hitTest()` +/// triggers SwiftUI view-body evaluation, AppKit can call back into this +/// function before the outer invocation finishes, causing a Swift +/// exclusive-access violation (SIGABRT). Main-thread only, no lock needed. +private var _windowDragHandleIsResolvingSiblingHits = false + +/// Returns whether the titlebar drag handle should capture a hit at `point`. +/// We only claim the hit when no sibling view already handles it, so interactive +/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures. +func windowDragHandleShouldCaptureHit( + _ point: NSPoint, + in dragHandleView: NSView, + eventType: NSEvent.EventType? = NSApp.currentEvent?.type, + eventWindow: NSWindow? = NSApp.currentEvent?.window +) -> Bool { + let dragHandleWindow = dragHandleView.window + + // Suppression recovery runs first so stale depth is cleared even for + // passive events — the associated-object reads/writes here are pure ObjC + // runtime calls and cannot trigger Swift exclusive-access violations. + if isWindowDragSuppressed(window: dragHandleWindow) { + // Recover from stale suppression if a prior interaction missed cleanup. + // We only keep suppression active while the left mouse button is down. + if (NSEvent.pressedMouseButtons & 0x1) == 0 { + let clearedDepth = clearWindowDragSuppression(window: dragHandleWindow) + windowDragHandleEmitBreadcrumb( + "titlebar.dragHandle.suppression.recovered", + window: dragHandleWindow, + eventType: eventType, + point: point, + minInterval: 20, + extraData: [ + "cleared_depth": clearedDepth + ] + ) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))" + ) + #endif + } else { + #if DEBUG + let depth = windowDragSuppressionDepth(window: dragHandleWindow) + dlog( + "titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))" + ) + #endif + return false + } + } + + // Bail out before the view-hierarchy walk so we never re-enter SwiftUI + // views during a layout pass — which causes exclusive-access crashes (#490). + if !windowDragHandleShouldResolveActiveHitCapture( + for: eventType, + eventWindow: eventWindow, + dragHandleWindow: dragHandleWindow + ) { + #if DEBUG + let eventTypeDescription = eventType.map { String(describing: $0) } ?? "nil" + let eventWindowNumber = eventWindow?.windowNumber ?? -1 + let dragWindowNumber = dragHandleWindow?.windowNumber ?? -1 + dlog( + "titlebar.dragHandle.hitTest capture=false reason=passiveEvent eventType=\(eventTypeDescription) eventWindow=\(eventWindowNumber) dragWindow=\(dragWindowNumber) point=\(windowDragHandleFormatPoint(point))" + ) + #endif + return false + } + + guard dragHandleView.bounds.contains(point) else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))") + #endif + return false + } + + guard let superview = dragHandleView.superview else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))") + #endif + return true + } + + // Bail out if we're already inside a sibling hit-test walk. This happens + // when sibling.hitTest() re-enters SwiftUI layout, which calls hitTest on + // this drag handle again. Proceeding would trigger an exclusive-access + // violation in the Swift runtime. + guard !_windowDragHandleIsResolvingSiblingHits else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=false reason=reentrant point=\(windowDragHandleFormatPoint(point))") + #endif + return false + } + + _windowDragHandleIsResolvingSiblingHits = true + defer { _windowDragHandleIsResolvingSiblingHits = false } + + let siblingSnapshot = Array(superview.subviews.reversed()) + + #if DEBUG + let siblingCount = siblingSnapshot.count + #endif + + for sibling in siblingSnapshot { + guard sibling !== dragHandleView else { continue } + guard !sibling.isHidden, sibling.alphaValue > 0 else { continue } + + let pointInSibling = dragHandleView.convert(point, to: sibling) + if let hitView = sibling.hitTest(pointInSibling) { + let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView) + if passiveHostHit { + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true" + ) + #endif + continue + } + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false" + ) + #endif + windowDragHandleEmitBreadcrumb( + "titlebar.dragHandle.hitTest.blockedBySiblingHit", + window: dragHandleWindow, + eventType: eventType, + point: point, + minInterval: 8, + extraData: [ + "sibling_type": String(describing: type(of: sibling)), + "hit_type": String(describing: type(of: hitView)) + ] + ) + return false + } + } + + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)") + #endif + return true +} + /// A transparent view that enables dragging the window when clicking in empty titlebar space. /// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content /// (e.g. sidebar tab reordering) don't move the whole window. @@ -14,8 +375,68 @@ struct WindowDragHandleView: NSViewRepresentable { } private final class DraggableView: NSView { - override var mouseDownCanMoveWindow: Bool { true } - override func hitTest(_ point: NSPoint) -> NSView? { self } + override var mouseDownCanMoveWindow: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + let currentEvent = NSApp.currentEvent + // Fast bail-out: only claim hits for left-mouse-down events. + // For mouseMoved / mouseEntered / etc., return nil immediately + // to avoid re-entering SwiftUI view state during layout passes, + // which causes exclusive-access crashes. + guard currentEvent?.type == .leftMouseDown else { + return nil + } + let shouldCapture = windowDragHandleShouldCaptureHit( + point, + in: self, + eventType: currentEvent?.type, + eventWindow: currentEvent?.window + ) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)" + ) + #endif + return shouldCapture ? self : nil + } + + override func mouseDown(with event: NSEvent) { + #if DEBUG + let point = convert(event.locationInWindow, from: nil) + let depth = windowDragSuppressionDepth(window: window) + dlog( + "titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)" + ) + #endif + + if event.clickCount >= 2 { + let handled = performStandardTitlebarDoubleClick(window: window) + #if DEBUG + dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)") + #endif + if handled { + return + } + } + + guard !isWindowDragSuppressed(window: window) else { + #if DEBUG + dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed") + #endif + return + } + + if let window { + let previousMovableState = withTemporaryWindowMovableEnabled(window: window) { + window.performDrag(with: event) + } + #if DEBUG + let restored = previousMovableState.map { String($0) } ?? "nil" + dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)") + #endif + } else { + super.mouseDown(with: event) + } + } } } - diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 547cd84b..1bc7e1ed 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,13 +3,614 @@ import SwiftUI import AppKit import Bonsplit import Combine +import CoreText + +func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { + switch context { + case GHOSTTY_SURFACE_CONTEXT_WINDOW: + return "window" + case GHOSTTY_SURFACE_CONTEXT_TAB: + return "tab" + case GHOSTTY_SURFACE_CONTEXT_SPLIT: + return "split" + default: + return "unknown(\(context))" + } +} + +func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { + guard let quicklookFont = ghostty_surface_quicklook_font(surface) else { + return nil + } + + let ctFont = Unmanaged<CTFont>.fromOpaque(quicklookFont).takeRetainedValue() + let points = Float(CTFontGetSize(ctFont)) + guard points > 0 else { return nil } + return points +} + +func cmuxInheritedSurfaceConfig( + sourceSurface: ghostty_surface_t, + context: ghostty_surface_context_e +) -> ghostty_surface_config_s { + let inherited = ghostty_surface_inherited_config(sourceSurface, context) + var config = inherited + + // Make runtime zoom inheritance explicit, even when Ghostty's + // inherit-font-size config is disabled. + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) + if let points = runtimePoints { + config.font_size = points + } + +#if DEBUG + let inheritedText = String(format: "%.2f", inherited.font_size) + let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil" + let finalText = String(format: "%.2f", config.font_size) + dlog( + "zoom.inherit context=\(cmuxSurfaceContextName(context)) " + + "inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)" + ) +#endif + + return config +} struct SidebarStatusEntry { let key: String let value: String let icon: String? let color: String? + let url: URL? + let priority: Int + let format: SidebarMetadataFormat let timestamp: Date + + init( + key: String, + value: String, + icon: String? = nil, + color: String? = nil, + url: URL? = nil, + priority: Int = 0, + format: SidebarMetadataFormat = .plain, + timestamp: Date = Date() + ) { + self.key = key + self.value = value + self.icon = icon + self.color = color + self.url = url + self.priority = priority + self.format = format + self.timestamp = timestamp + } +} + +struct SidebarMetadataBlock { + let key: String + let markdown: String + let priority: Int + let timestamp: Date +} + +enum SidebarMetadataFormat: String { + case plain + case markdown +} + +private struct SessionPaneRestoreEntry { + let paneId: PaneID + let snapshot: SessionPaneLayoutSnapshot +} + +extension Workspace { + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { + let tree = bonsplitController.treeSnapshot() + let layout = sessionLayoutSnapshot(from: tree) + + let orderedPanelIds = sidebarOrderedPanelIds() + var seen: Set<UUID> = [] + var allPanelIds: [UUID] = [] + for panelId in orderedPanelIds where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + + let panelSnapshots = allPanelIds + .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) + .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } + + let statusSnapshots = statusEntries.values + .sorted { lhs, rhs in lhs.key < rhs.key } + .map { entry in + SessionStatusEntrySnapshot( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + let logSnapshots = logEntries.map { entry in + SessionLogEntrySnapshot( + message: entry.message, + level: entry.level.rawValue, + source: entry.source, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + + let progressSnapshot = progress.map { progress in + SessionProgressSnapshot(value: progress.value, label: progress.label) + } + let gitBranchSnapshot = gitBranch.map { branch in + SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) + } + + return SessionWorkspaceSnapshot( + processTitle: processTitle, + customTitle: customTitle, + customColor: customColor, + isPinned: isPinned, + currentDirectory: currentDirectory, + focusedPanelId: focusedPanelId, + layout: layout, + panels: panelSnapshots, + statusEntries: statusSnapshots, + logEntries: logSnapshots, + progress: progressSnapshot, + gitBranch: gitBranchSnapshot + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + + let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedCurrentDirectory.isEmpty { + currentDirectory = normalizedCurrentDirectory + } + + let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) + let leafEntries = restoreSessionLayout(snapshot.layout) + var oldToNewPanelIds: [UUID: UUID] = [:] + + for entry in leafEntries { + restorePane( + entry.paneId, + snapshot: entry.snapshot, + panelSnapshotsById: panelSnapshotsById, + oldToNewPanelIds: &oldToNewPanelIds + ) + } + + pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) + applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + + applyProcessTitle(snapshot.processTitle) + setCustomTitle(snapshot.customTitle) + setCustomColor(snapshot.customColor) + isPinned = snapshot.isPinned + + statusEntries = Dictionary( + uniqueKeysWithValues: snapshot.statusEntries.map { entry in + ( + entry.key, + SidebarStatusEntry( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + ) + } + ) + logEntries = snapshot.logEntries.map { entry in + SidebarLogEntry( + message: entry.message, + level: SidebarLogLevel(rawValue: entry.level) ?? .info, + source: entry.source, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + } + progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } + gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } + + recomputeListeningPorts() + + if let focusedOldPanelId = snapshot.focusedPanelId, + let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], + panels[focusedNewPanelId] != nil { + focusPanel(focusedNewPanelId) + } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { + focusPanel(fallbackFocusedPanelId) + } else { + scheduleFocusReconcile() + } + } + + private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { + switch node { + case .pane(let pane): + let panelIds = sessionPanelIDs(for: pane) + let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) + return .pane( + SessionPaneLayoutSnapshot( + panelIds: panelIds, + selectedPanelId: selectedPanelId + ) + ) + case .split(let split): + return .split( + SessionSplitLayoutSnapshot( + orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + dividerPosition: split.dividerPosition, + first: sessionLayoutSnapshot(from: split.first), + second: sessionLayoutSnapshot(from: split.second) + ) + ) + } + } + + private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { + var panelIds: [UUID] = [] + var seen = Set<UUID>() + for tab in pane.tabs { + guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } + if seen.insert(panelId).inserted { + panelIds.append(panelId) + } + } + return panelIds + } + + private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? { + guard let tabUUID = UUID(uuidString: tabIDString) else { return nil } + for (surfaceId, panelId) in surfaceIdToPanelId { + guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue } + if surfaceUUID == tabUUID { + return panelId + } + } + return nil + } + + private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { + struct EncodedSurfaceID: Decodable { + let id: UUID + } + + guard let data = try? JSONEncoder().encode(surfaceId), + let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + return nil + } + return decoded.id + } + + private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { + guard let panel = panels[panelId] else { return nil } + + let panelTitle = panelTitle(panelId: panelId) + let customTitle = panelCustomTitles[panelId] + let directory = panelDirectories[panelId] + let isPinned = pinnedPanelIds.contains(panelId) + let isManuallyUnread = manualUnreadPanelIds.contains(panelId) + let branchSnapshot = panelGitBranches[panelId].map { + SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) + } + let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + let ttyName = surfaceTTYNames[panelId] + + let terminalSnapshot: SessionTerminalPanelSnapshot? + let browserSnapshot: SessionBrowserPanelSnapshot? + let markdownSnapshot: SessionMarkdownPanelSnapshot? + switch panel.panelType { + case .terminal: + guard let terminalPanel = panel as? TerminalPanel else { return nil } + let capturedScrollback = includeScrollback + ? TerminalController.shared.readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + includeScrollback: true, + lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal + ) + : nil + let resolvedScrollback = terminalSnapshotScrollback( + panelId: panelId, + capturedScrollback: capturedScrollback, + includeScrollback: includeScrollback + ) + terminalSnapshot = SessionTerminalPanelSnapshot( + workingDirectory: panelDirectories[panelId], + scrollback: resolvedScrollback + ) + browserSnapshot = nil + markdownSnapshot = nil + case .browser: + guard let browserPanel = panel as? BrowserPanel else { return nil } + terminalSnapshot = nil + let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() + browserSnapshot = SessionBrowserPanelSnapshot( + urlString: browserPanel.preferredURLStringForOmnibar(), + shouldRenderWebView: browserPanel.shouldRenderWebView, + pageZoom: Double(browserPanel.webView.pageZoom), + developerToolsVisible: browserPanel.isDeveloperToolsVisible(), + backHistoryURLStrings: historySnapshot.backHistoryURLStrings, + forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings + ) + markdownSnapshot = nil + case .markdown: + guard let mdPanel = panel as? MarkdownPanel else { return nil } + terminalSnapshot = nil + browserSnapshot = nil + markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath) + } + + return SessionPanelSnapshot( + id: panelId, + type: panel.panelType, + title: panelTitle, + customTitle: customTitle, + directory: directory, + isPinned: isPinned, + isManuallyUnread: isManuallyUnread, + gitBranch: branchSnapshot, + listeningPorts: listeningPorts, + ttyName: ttyName, + terminal: terminalSnapshot, + browser: browserSnapshot, + markdown: markdownSnapshot + ) + } + + nonisolated static func resolvedSnapshotTerminalScrollback( + capturedScrollback: String?, + fallbackScrollback: String? + ) -> String? { + if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { + return captured + } + return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) + } + + private func terminalSnapshotScrollback( + panelId: UUID, + capturedScrollback: String?, + includeScrollback: Bool + ) -> String? { + guard includeScrollback else { return nil } + let fallback = restoredTerminalScrollbackByPanelId[panelId] + let resolved = Self.resolvedSnapshotTerminalScrollback( + capturedScrollback: capturedScrollback, + fallbackScrollback: fallback + ) + if let resolved { + restoredTerminalScrollbackByPanelId[panelId] = resolved + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + } + return resolved + } + + private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { + guard let rootPaneId = bonsplitController.allPaneIds.first else { + return [] + } + + var leaves: [SessionPaneRestoreEntry] = [] + restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) + return leaves + } + + private func restoreSessionLayoutNode( + _ node: SessionWorkspaceLayoutSnapshot, + inPane paneId: PaneID, + leaves: inout [SessionPaneRestoreEntry] + ) { + switch node { + case .pane(let pane): + leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) + case .split(let split): + var anchorPanelId = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + .first + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.orientation.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append( + SessionPaneRestoreEntry( + paneId: paneId, + snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + ) + ) + return + } + + restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) + restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + } + } + + private func restorePane( + _ paneId: PaneID, + snapshot: SessionPaneLayoutSnapshot, + panelSnapshotsById: [UUID: SessionPanelSnapshot], + oldToNewPanelIds: inout [UUID: UUID] + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } + + var createdPanelIds: [UUID] = [] + for oldPanelId in desiredOldPanelIds { + guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } + guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + createdPanelIds.append(createdPanelId) + oldToNewPanelIds[oldPanelId] = createdPanelId + } + + guard !createdPanelIds.isEmpty else { return } + + for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { + _ = closePanel(oldPanelId, force: true) + } + + for (index, panelId) in createdPanelIds.enumerated() { + _ = reorderSurface(panelId: panelId, toIndex: index) + } + + let selectedPanelId: UUID? = { + if let selectedOldId = snapshot.selectedPanelId { + return oldToNewPanelIds[selectedOldId] + } + return createdPanelIds.first + }() + + if let selectedPanelId, + let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(selectedTabId) + } + } + + private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + switch snapshot.type { + case .terminal: + let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory + let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( + for: snapshot.terminal?.scrollback + ) + guard let terminalPanel = newTerminalSurface( + inPane: paneId, + focus: false, + workingDirectory: workingDirectory, + startupEnvironment: replayEnvironment + ) else { + return nil + } + let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) + if let fallbackScrollback { + restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) + } + applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) + return terminalPanel.id + case .browser: + let initialURL = snapshot.browser?.urlString.flatMap { URL(string: $0) } + guard let browserPanel = newBrowserSurface( + inPane: paneId, + url: initialURL, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) + return browserPanel.id + case .markdown: + guard let filePath = snapshot.markdown?.filePath else { + return nil + } + guard let markdownPanel = newMarkdownSurface( + inPane: paneId, + filePath: filePath, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) + return markdownPanel.id + } + } + + private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { + if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + panelTitles[panelId] = title + } + + setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) + setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) + + if snapshot.isManuallyUnread { + markPanelUnread(panelId) + } else { + clearManualUnread(panelId: panelId) + } + + if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { + updatePanelDirectory(panelId: panelId, directory: directory) + } + + if let branch = snapshot.gitBranch { + panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) + } else { + panelGitBranches.removeValue(forKey: panelId) + } + + surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + + if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[panelId] = ttyName + } else { + surfaceTTYNames.removeValue(forKey: panelId) + } + + if let browserSnapshot = snapshot.browser, + let browserPanel = browserPanel(for: panelId) { + browserPanel.restoreSessionNavigationHistory( + backHistoryURLStrings: browserSnapshot.backHistoryURLStrings ?? [], + forwardHistoryURLStrings: browserSnapshot.forwardHistoryURLStrings ?? [], + currentURLString: browserSnapshot.urlString + ) + + let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) + if pageZoom.isFinite { + browserPanel.webView.pageZoom = pageZoom + } + + if browserSnapshot.developerToolsVisible { + _ = browserPanel.showDeveloperTools() + browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") + } else { + _ = browserPanel.hideDeveloperTools() + } + } + } + + private func applySessionDividerPositions( + snapshotNode: SessionWorkspaceLayoutSnapshot, + liveNode: ExternalTreeNode + ) { + switch (snapshotNode, liveNode) { + case (.split(let snapshotSplit), .split(let liveSplit)): + if let splitID = UUID(uuidString: liveSplit.id) { + _ = bonsplitController.setDividerPosition( + CGFloat(snapshotSplit.dividerPosition), + forSplit: splitID, + fromExternal: true + ) + } + applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) + applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) + default: + return + } + } } enum SidebarLogLevel: String { @@ -37,6 +638,19 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum SidebarPullRequestStatus: String { + case open + case merged + case closed +} + +struct SidebarPullRequestState: Equatable { + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus +} + enum SidebarBranchOrdering { struct BranchEntry: Equatable { let name: String @@ -117,6 +731,65 @@ enum SidebarBranchOrdering { } } + static func orderedUniquePullRequests( + orderedPanelIds: [UUID], + panelPullRequests: [UUID: SidebarPullRequestState], + fallbackPullRequest: SidebarPullRequestState? + ) -> [SidebarPullRequestState] { + func statusPriority(_ status: SidebarPullRequestStatus) -> Int { + switch status { + case .merged: return 3 + case .open: return 2 + case .closed: return 1 + } + } + + func normalizedReviewURLKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + + // Treat URL variants that differ only by query/fragment as the same review item. + components.query = nil + components.fragment = nil + let scheme = components.scheme?.lowercased() ?? "" + let host = components.host?.lowercased() ?? "" + let port = components.port.map { ":\($0)" } ?? "" + var path = components.path + if path.hasSuffix("/"), path.count > 1 { + path.removeLast() + } + return "\(scheme)://\(host)\(port)\(path)" + } + + func reviewKey(for state: SidebarPullRequestState) -> String { + "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" + } + + var orderedKeys: [String] = [] + var pullRequestsByKey: [String: SidebarPullRequestState] = [:] + + for panelId in orderedPanelIds { + guard let state = panelPullRequests[panelId] else { continue } + let key = reviewKey(for: state) + if pullRequestsByKey[key] == nil { + orderedKeys.append(key) + pullRequestsByKey[key] = state + continue + } + guard let existing = pullRequestsByKey[key] else { continue } + if statusPriority(state.status) > statusPriority(existing.status) { + pullRequestsByKey[key] = state + } + } + + if orderedKeys.isEmpty, let fallbackPullRequest { + return [fallbackPullRequest] + } + + return orderedKeys.compactMap { pullRequestsByKey[$0] } + } + static func orderedUniqueBranchDirectoryEntries( orderedPanelIds: [UUID], panelBranches: [UUID: SidebarGitBranchState], @@ -243,6 +916,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var title: String @Published var customTitle: String? @Published var isPinned: Bool = false + @Published var customColor: String? // hex string, e.g. "#C0392B" @Published var currentDirectory: String /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) @@ -259,6 +933,16 @@ final class Workspace: Identifiable, ObservableObject { /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) private var isProgrammaticSplit = false + private var debugStressPreloadSelectionDepth = 0 + + /// Last terminal panel used as an inheritance source (typically last focused terminal). + private var lastTerminalConfigInheritancePanelId: UUID? + /// Last known terminal font points from inheritance sources. Used as fallback when + /// no live terminal surface is currently available. + private var lastTerminalConfigInheritanceFontPoints: Float? + /// Per-panel inherited zoom lineage. Descendants reuse this root value unless + /// a panel is explicitly re-zoomed by the user. + private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? @@ -285,6 +969,15 @@ final class Workspace: Identifiable, ObservableObject { return panel } + func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? { + bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) } + } + + enum FocusPanelTrigger { + case standard + case terminalFirstResponder + } + /// Published directory for each panel @Published var panelDirectories: [UUID: String] = [:] @Published var panelTitles: [UUID: String] = [:] @@ -295,13 +988,17 @@ final class Workspace: Identifiable, ObservableObject { nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @Published var statusEntries: [String: SidebarStatusEntry] = [:] + @Published var metadataBlocks: [String: SidebarMetadataBlock] = [:] @Published var logEntries: [SidebarLogEntry] = [] @Published var progress: SidebarProgressState? @Published var gitBranch: SidebarGitBranchState? @Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:] + @Published var pullRequest: SidebarPullRequestState? + @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] + private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { @@ -314,60 +1011,106 @@ final class Workspace: Identifiable, ObservableObject { private enum SurfaceKind { static let terminal = "terminal" static let browser = "browser" + static let markdown = "markdown" } // MARK: - Initialization private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { BonsplitConfiguration.SplitButtonTooltips( - newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), - newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), - splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), - splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip(String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal")), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip(String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser")), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip(String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right")), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip(String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down")) ) } private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { - bonsplitAppearance(from: config.backgroundColor) - } - - private static func usesDarkChrome( - appAppearance: NSAppearance? = NSApp?.effectiveAppearance - ) -> Bool { - guard let appAppearance else { return false } - return appAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - } - - private static func resolvedChromeBackgroundHex( - from backgroundColor: NSColor, - appAppearance: NSAppearance? = NSApp?.effectiveAppearance - ) -> String? { - guard usesDarkChrome(appAppearance: appAppearance) else { return nil } - return backgroundColor.hexString() - } - - private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { - let backgroundHex = resolvedChromeBackgroundHex(from: backgroundColor) - return BonsplitConfiguration.Appearance( - splitButtonTooltips: Self.currentSplitButtonTooltips(), - enableAnimations: false, - chromeColors: .init(backgroundHex: backgroundHex) + bonsplitAppearance( + from: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity ) } - func applyGhosttyChrome(from config: GhosttyConfig) { - applyGhosttyChrome(backgroundColor: config.backgroundColor) + static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String { + let themedColor = GhosttyBackgroundTheme.color( + backgroundColor: backgroundColor, + opacity: backgroundOpacity + ) + let includeAlpha = themedColor.alphaComponent < 0.999 + return themedColor.hexString(includeAlpha: includeAlpha) } - func applyGhosttyChrome(backgroundColor: NSColor) { - let nextHex = Self.resolvedChromeBackgroundHex(from: backgroundColor) - if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextHex { + nonisolated static func resolvedChromeColors( + from backgroundColor: NSColor + ) -> BonsplitConfiguration.Appearance.ChromeColors { + .init(backgroundHex: backgroundColor.hexString()) + } + + private static func bonsplitAppearance( + from backgroundColor: NSColor, + backgroundOpacity: Double + ) -> BonsplitConfiguration.Appearance { + BonsplitConfiguration.Appearance( + splitButtonTooltips: Self.currentSplitButtonTooltips(), + enableAnimations: false, + chromeColors: .init( + backgroundHex: Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) + ) + ) + } + + func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { + applyGhosttyChrome( + backgroundColor: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity, + reason: reason + ) + } + + func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { + let nextHex = Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) + let currentChromeColors = bonsplitController.configuration.appearance.chromeColors + let isNoOp = currentChromeColors.backgroundHex == nextHex + + if GhosttyApp.shared.backgroundLogEnabled { + let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" + GhosttyApp.shared.logBackground( + "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)" + ) + } + + if isNoOp { return } bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" + ) + } } - init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) { + func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { + applyGhosttyChrome( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundColor.alphaComponent, + reason: reason + ) + } + + init( + title: String = "Terminal", + workingDirectory: String? = nil, + portOrdinal: Int = 0, + configTemplate: ghostty_surface_config_s? = nil + ) { self.id = UUID() self.portOrdinal = portOrdinal self.processTitle = title @@ -384,7 +1127,10 @@ final class Workspace: Identifiable, ObservableObject { // and keep split entry instantaneous. // Avoid re-reading/parsing Ghostty config on every new workspace; this hot path // runs for socket/CLI workspace creation and can cause visible typing lag. - let appearance = Self.bonsplitAppearance(from: GhosttyApp.shared.defaultBackgroundColor) + let appearance = Self.bonsplitAppearance( + from: GhosttyApp.shared.defaultBackgroundColor, + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + ) let config = BonsplitConfiguration( allowSplits: true, allowCloseTabs: true, @@ -397,6 +1143,7 @@ final class Workspace: Identifiable, ObservableObject { appearance: appearance ) self.bonsplitController = BonsplitController(configuration: config) + bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts() // Remove the default "Welcome" tab that bonsplit creates let welcomeTabIds = bonsplitController.allTabIds @@ -405,11 +1152,13 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) // Create initial tab in bonsplit and store the mapping var initialTabId: TabID? @@ -429,6 +1178,10 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.closeTab(welcomeTabId) } + bonsplitController.onExternalTabDrop = { [weak self] request in + self?.handleExternalTabDrop(request) ?? false + } + // Set ourselves as delegate bonsplitController.delegate = self @@ -482,11 +1235,32 @@ final class Workspace: Identifiable, ObservableObject { private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:] private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] private var isApplyingTabSelection = false - private var pendingTabSelection: (tabId: TabID, pane: PaneID)? + private struct PendingTabSelectionRequest { + let tabId: TabID + let pane: PaneID + let reassertAppKitFocus: Bool + let focusIntent: PanelFocusIntent? + let previousTerminalHostedView: GhosttySurfaceScrollView? + } + private var pendingTabSelection: PendingTabSelectionRequest? private var isReconcilingFocusState = false private var focusReconcileScheduled = false +#if DEBUG + private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 + private var debugLastDidMoveTabTimestamp: TimeInterval = 0 + private var debugDidMoveTabEventCount: UInt64 = 0 +#endif private var geometryReconcileScheduled = false + private var geometryReconcileNeedsRerun = false private var isNormalizingPinnedTabOrder = false + private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? + private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 + + private struct PendingNonFocusSplitFocusReassert { + let generation: UInt64 + let preferredPanelId: UUID + let splitPanelId: UUID + } struct DetachedSurfaceTransfer { let panelId: UUID @@ -505,6 +1279,15 @@ final class Workspace: Identifiable, ObservableObject { private var detachingTabIds: Set<TabID> = [] private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] + private var activeDetachCloseTransactions: Int = 0 + private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } + +#if DEBUG + private func debugElapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } +#endif func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { surfaceIdToPanelId[surfaceId] @@ -548,6 +1331,31 @@ final class Workspace: Identifiable, ObservableObject { } panelSubscriptions[browserPanel.id] = subscription } + + private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { + let subscription = markdownPanel.$displayTitle + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self, weak markdownPanel] newTitle in + guard let self = self, + let markdownPanel = markdownPanel, + let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } + guard let existing = self.bonsplitController.tab(tabId) else { return } + + if self.panelTitles[markdownPanel.id] != newTitle { + self.panelTitles[markdownPanel.id] = newTitle + } + let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle) + guard existing.title != resolvedTitle else { return } + self.bonsplitController.updateTab( + tabId, + title: resolvedTitle, + hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil + ) + } + panelSubscriptions[markdownPanel.id] = subscription + } + // MARK: - Panel Access func panel(for surfaceId: TabID) -> (any Panel)? { @@ -563,12 +1371,18 @@ final class Workspace: Identifiable, ObservableObject { panels[panelId] as? BrowserPanel } + func markdownPanel(for panelId: UUID) -> MarkdownPanel? { + panels[panelId] as? MarkdownPanel + } + private func surfaceKind(for panel: any Panel) -> String { switch panel.panelType { case .terminal: return SurfaceKind.terminal case .browser: return SurfaceKind.browser + case .markdown: + return SurfaceKind.markdown } } @@ -678,6 +1492,48 @@ final class Workspace: Identifiable, ObservableObject { return surfaceKind(for: panel) } + func requestBackgroundTerminalSurfaceStartIfNeeded() { + for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } + + @discardableResult + func preloadTerminalPanelForDebugStress( + tabId: TabID, + inPane paneId: PaneID + ) -> TerminalPanel? { + guard let panelId = panelIdFromSurfaceId(tabId), + let terminalPanel = panels[panelId] as? TerminalPanel else { + return nil + } + + debugStressPreloadSelectionDepth += 1 + defer { debugStressPreloadSelectionDepth -= 1 } + let isVisibleSelection = + bonsplitController.focusedPaneId == paneId && + bonsplitController.selectedTab(inPane: paneId)?.id == tabId && + terminalPanel.hostedView.window != nil && + terminalPanel.hostedView.superview != nil + + if isVisibleSelection { + terminalPanel.requestViewReattach() + scheduleTerminalGeometryReconcile() + } + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + return terminalPanel + } + + func scheduleDebugStressTerminalGeometryReconcile() { + scheduleTerminalGeometryReconcile() + } + + func hasLoadedTerminalSurface() -> Bool { + let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel } + guard !terminalPanels.isEmpty else { return true } + return terminalPanels.contains { $0.surface.surface != nil } + } + func panelTitle(panelId: UUID) -> String? { guard let panel = panels[panelId] else { return nil } let fallback = panelTitles[panelId] ?? panel.displayTitle @@ -755,6 +1611,14 @@ final class Workspace: Identifiable, ObservableObject { self.title = title } + func setCustomColor(_ hex: String?) { + if let hex { + customColor = WorkspaceTabColorSettings.normalizedHex(hex) + } else { + customColor = nil + } + } + func setCustomTitle(_ title: String?) { let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { @@ -798,6 +1662,81 @@ final class Workspace: Identifiable, ObservableObject { } } + func updatePanelPullRequest( + panelId: UUID, + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus + ) { + let state = SidebarPullRequestState(number: number, label: label, url: url, status: status) + let existing = panelPullRequests[panelId] + if existing != state { + panelPullRequests[panelId] = state + } + if panelId == focusedPanelId { + pullRequest = state + } + } + + func clearPanelPullRequest(panelId: UUID) { + panelPullRequests.removeValue(forKey: panelId) + if panelId == focusedPanelId { + pullRequest = nil + } + } + + func resetSidebarContext(reason: String = "unspecified") { + statusEntries.removeAll() + logEntries.removeAll() + progress = nil + gitBranch = nil + panelGitBranches.removeAll() + pullRequest = nil + panelPullRequests.removeAll() + surfaceListeningPorts.removeAll() + listeningPorts.removeAll() + metadataBlocks.removeAll() + resetBrowserPanelsForContextChange(reason: reason) + } + + func resetBrowserPanelsForContextChange(reason: String) { + let browserPanels = panels.values.compactMap { $0 as? BrowserPanel } + guard !browserPanels.isEmpty else { return } + +#if DEBUG + dlog( + "workspace.contextReset.browserPanels workspace=\(id.uuidString.prefix(5)) " + + "reason=\(reason) count=\(browserPanels.count)" + ) +#endif + + for browserPanel in browserPanels { + browserPanel.resetForWorkspaceContextChange(reason: reason) + let nextTitle = browserPanel.displayTitle + _ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle) + + guard let tabId = surfaceIdFromPanelId(browserPanel.id), + let existing = bonsplitController.tab(tabId) else { + continue + } + + let faviconUpdate: Data?? = existing.iconImageData == nil ? nil : .some(nil) + let loadingUpdate: Bool? = existing.isLoading ? false : nil + + guard faviconUpdate != nil || loadingUpdate != nil else { + continue + } + + bonsplitController.updateTab( + tabId, + iconImageData: faviconUpdate, + hasCustomTitle: panelCustomTitles[browserPanel.id] != nil, + isLoading: loadingUpdate + ) + } + } + @discardableResult func updatePanelTitle(panelId: UUID, title: String) -> Bool { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -846,6 +1785,7 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } + panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() } @@ -876,19 +1816,25 @@ final class Workspace: Identifiable, ObservableObject { ) } - func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { + func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] { SidebarBranchOrdering .orderedUniqueBranches( - orderedPanelIds: sidebarOrderedPanelIds(), + orderedPanelIds: orderedPanelIds, panelBranches: panelGitBranches, fallbackBranch: gitBranch ) .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } } - func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { + func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { + sidebarGitBranchesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) + } + + func sidebarBranchDirectoryEntriesInDisplayOrder( + orderedPanelIds: [UUID] + ) -> [SidebarBranchOrdering.BranchDirectoryEntry] { SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( - orderedPanelIds: sidebarOrderedPanelIds(), + orderedPanelIds: orderedPanelIds, panelBranches: panelGitBranches, panelDirectories: panelDirectories, defaultDirectory: currentDirectory, @@ -896,8 +1842,203 @@ final class Workspace: Identifiable, ObservableObject { ) } + func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { + sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) + } + + func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { + SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: orderedPanelIds, + panelPullRequests: panelPullRequests, + fallbackPullRequest: pullRequest + ) + } + + func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] { + sidebarPullRequestsInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) + } + + func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { + statusEntries.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } + } + + func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] { + metadataBlocks.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } + } + // MARK: - Panel Operations + private func seedTerminalInheritanceFontPoints( + panelId: UUID, + configTemplate: ghostty_surface_config_s? + ) { + guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return } + terminalInheritanceFontPointsByPanelId[panelId] = fontPoints + lastTerminalConfigInheritanceFontPoints = fontPoints + } + + private func resolvedTerminalInheritanceFontPoints( + for terminalPanel: TerminalPanel, + sourceSurface: ghostty_surface_t, + inheritedConfig: ghostty_surface_config_s + ) -> Float? { + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) + if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { + if let runtimePoints, abs(runtimePoints - rooted) > 0.05 { + // Runtime zoom changed after lineage was seeded (manual zoom on descendant); + // treat runtime as the new root for future descendants. + return runtimePoints + } + return rooted + } + if inheritedConfig.font_size > 0 { + return inheritedConfig.font_size + } + return runtimePoints + } + + private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { + lastTerminalConfigInheritancePanelId = terminalPanel.id + if let sourceSurface = terminalPanel.surface.surface, + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { + let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] + if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints + } + lastTerminalConfigInheritanceFontPoints = + terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints + } + } + + func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { + guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } + return terminalPanel(for: panelId) + } + + func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { + lastTerminalConfigInheritanceFontPoints + } + + /// Candidate terminal panels used as the source when creating inherited Ghostty config. + /// Preference order: + /// 1) explicitly preferred terminal panel (when the caller has one), + /// 2) selected terminal in the target pane, + /// 3) currently focused terminal in the workspace, + /// 4) last remembered terminal source, + /// 5) first terminal tab in the target pane, + /// 6) deterministic workspace fallback. + private func terminalPanelConfigInheritanceCandidates( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> [TerminalPanel] { + var candidates: [TerminalPanel] = [] + var seen: Set<UUID> = [] + + func appendCandidate(_ panel: TerminalPanel?) { + guard let panel, seen.insert(panel.id).inserted else { return } + candidates.append(panel) + } + + if let preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) { + appendCandidate(terminalPanel) + } + + if let preferredPaneId, + let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, + let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), + let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { + appendCandidate(selectedTerminalPanel) + } + + if let focusedTerminalPanel { + appendCandidate(focusedTerminalPanel) + } + + if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() { + appendCandidate(rememberedTerminalPanel) + } + + if let preferredPaneId { + for tab in bonsplitController.tabs(inPane: preferredPaneId) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId) else { continue } + appendCandidate(terminalPanel) + } + } + + for terminalPanel in panels.values + .compactMap({ $0 as? TerminalPanel }) + .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + appendCandidate(terminalPanel) + } + + return candidates + } + + /// Picks the first terminal panel candidate used as the inheritance source. + func terminalPanelForConfigInheritance( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> TerminalPanel? { + terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ).first + } + + private func inheritedTerminalConfig( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> ghostty_surface_config_s? { + // Walk candidates in priority order and use the first panel with a live surface. + // This avoids returning nil when the top candidate exists but is not attached yet. + for terminalPanel in terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ) { + guard let sourceSurface = terminalPanel.surface.surface else { continue } + var config = cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT + ) + if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( + for: terminalPanel, + sourceSurface: sourceSurface, + inheritedConfig: config + ), rootedFontPoints > 0 { + config.font_size = rootedFontPoints + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints + } + rememberTerminalConfigInheritanceSource(terminalPanel) + if config.font_size > 0 { + lastTerminalConfigInheritanceFontPoints = config.font_size + } + return config + } + + if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints +#if DEBUG + dlog( + "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" + ) +#endif + return config + } + + return nil + } + /// Create a new split with a terminal panel @discardableResult func newTerminalSplit( @@ -906,22 +2047,6 @@ final class Workspace: Identifiable, ObservableObject { insertFirst: Bool = false, focus: Bool = true ) -> TerminalPanel? { - // Get inherited config from the source terminal when possible. - // If the split is initiated from a non-terminal panel (for example browser), - // fall back to any terminal in the workspace. - let inheritedConfig: ghostty_surface_config_s? = { - if let sourceTerminal = terminalPanel(for: panelId), - let existing = sourceTerminal.surface.surface { - return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - if let fallbackSurface = panels.values - .compactMap({ ($0 as? TerminalPanel)?.surface.surface }) - .first { - return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - return nil - }() - // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? @@ -934,16 +2059,28 @@ final class Workspace: Identifiable, ObservableObject { } guard let paneId = sourcePaneId else { return nil } + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + + // Inherit working directory: prefer the source panel's reported cwd, + // fall back to the workspace's current directory. + let splitWorkingDirectory: String? = panelDirectories[panelId] + ?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil : currentDirectory) +#if DEBUG + dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")") +#endif // Create the new terminal panel. let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, + workingDirectory: splitWorkingDirectory, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit // mutates layout state (avoids transient "Empty Panel" flashes during split). @@ -955,69 +2092,74 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = newPanel.id + let previousFocusedPanelId = focusedPanelId - // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, - // so we can hand it to focusPanel as the "move focus FROM" view. - let previousHostedView = focusedTerminalPanel?.hostedView + // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, + // so we can hand it to focusPanel as the "move focus FROM" view. + let previousHostedView = focusedTerminalPanel?.hostedView - // Create the split with the new tab already present in the new pane. - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - surfaceIdToPanelId.removeValue(forKey: newTab.id) - return nil - } + // Create the split with the new tab already present in the new pane. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + surfaceIdToPanelId.removeValue(forKey: newTab.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return nil + } #if DEBUG - dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") + dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") #endif - // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. - // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, - // stealing focus from the new panel and creating model/surface divergence. - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - scheduleFocusReconcile() - } + // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. + // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, + // stealing focus from the new panel and creating model/surface divergence. + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(newPanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) + } - return newPanel - } + return newPanel + } /// Create a new surface (nested tab) in the specified pane with a terminal panel. /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), /// true = force focus/selection of the new surface, /// false = never focus (used for internal placeholder repair paths). @discardableResult - func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? { + func newTerminalSurface( + inPane paneId: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] + ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - // Get an existing terminal panel to inherit config from - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) // Create new terminal panel let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, + workingDirectory: workingDirectory, + additionalEnvironment: startupEnvironment, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( @@ -1030,6 +2172,7 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -1084,29 +2227,34 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = browserPanel.id + let previousFocusedPanelId = focusedPanelId - // Create the split with the browser tab already present. - // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - surfaceIdToPanelId.removeValue(forKey: newTab.id) - panels.removeValue(forKey: browserPanel.id) - panelTitles.removeValue(forKey: browserPanel.id) - return nil - } + // Create the split with the browser tab already present. + // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + surfaceIdToPanelId.removeValue(forKey: newTab.id) + panels.removeValue(forKey: browserPanel.id) + panelTitles.removeValue(forKey: browserPanel.id) + return nil + } - // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. - let previousHostedView = focusedTerminalPanel?.hostedView - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(browserPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - scheduleFocusReconcile() - } + // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. + let previousHostedView = focusedTerminalPanel?.hostedView + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(browserPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) + } installBrowserPanelSubscription(browserPanel) @@ -1170,31 +2318,229 @@ final class Workspace: Identifiable, ObservableObject { return browserPanel } + // MARK: - Markdown Panel Creation + + /// Create a new markdown panel split from an existing panel. + func newMarkdownSplit( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + filePath: String, + focus: Bool = true + ) -> MarkdownPanel? { + // Find the pane containing the source panel + guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } + var sourcePaneId: PaneID? + for paneId in bonsplitController.allPaneIds { + let tabs = bonsplitController.tabs(inPane: paneId) + if tabs.contains(where: { $0.id == sourceTabId }) { + sourcePaneId = paneId + break + } + } + + guard let paneId = sourcePaneId else { return nil } + + // Create markdown panel + let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) + panels[markdownPanel.id] = markdownPanel + panelTitles[markdownPanel.id] = markdownPanel.displayTitle + + // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. + let newTab = Bonsplit.Tab( + title: markdownPanel.displayTitle, + icon: markdownPanel.displayIcon, + kind: SurfaceKind.markdown, + isDirty: markdownPanel.isDirty, + isLoading: false, + isPinned: false + ) + surfaceIdToPanelId[newTab.id] = markdownPanel.id + let previousFocusedPanelId = focusedPanelId + + // Create the split with the markdown tab already present in the new pane. + // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + surfaceIdToPanelId.removeValue(forKey: newTab.id) + panels.removeValue(forKey: markdownPanel.id) + panelTitles.removeValue(forKey: markdownPanel.id) + return nil + } + + // Suppress old view's becomeFirstResponder during reparenting. + let previousHostedView = focusedTerminalPanel?.hostedView + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(markdownPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: markdownPanel.id, + previousHostedView: previousHostedView + ) + } + + installMarkdownPanelSubscription(markdownPanel) + + return markdownPanel + } + + /// Create a new markdown surface (tab) in the specified pane. + @discardableResult + func newMarkdownSurface( + inPane paneId: PaneID, + filePath: String, + focus: Bool? = nil + ) -> MarkdownPanel? { + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + + let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) + panels[markdownPanel.id] = markdownPanel + panelTitles[markdownPanel.id] = markdownPanel.displayTitle + + guard let newTabId = bonsplitController.createTab( + title: markdownPanel.displayTitle, + icon: markdownPanel.displayIcon, + kind: SurfaceKind.markdown, + isDirty: markdownPanel.isDirty, + isLoading: false, + isPinned: false, + inPane: paneId + ) else { + panels.removeValue(forKey: markdownPanel.id) + panelTitles.removeValue(forKey: markdownPanel.id) + return nil + } + + surfaceIdToPanelId[newTabId] = markdownPanel.id + + // Match terminal behavior: enforce deterministic selection + focus. + if shouldFocusNewTab { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + applyTabSelection(tabId: newTabId, inPane: paneId) + } + + installMarkdownPanelSubscription(markdownPanel) + + return markdownPanel + } + + /// Tear down all panels in this workspace, freeing their Ghostty surfaces. + /// Called before the workspace is removed from TabManager to ensure child + /// processes receive SIGHUP even if ARC deallocation is delayed. + func teardownAllPanels() { + let panelEntries = Array(panels) + for (panelId, panel) in panelEntries { + panelSubscriptions.removeValue(forKey: panelId) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + panel.close() + } + + panels.removeAll(keepingCapacity: false) + surfaceIdToPanelId.removeAll(keepingCapacity: false) + panelSubscriptions.removeAll(keepingCapacity: false) + pruneSurfaceMetadata(validSurfaceIds: []) + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) + lastTerminalConfigInheritancePanelId = nil + lastTerminalConfigInheritanceFontPoints = nil + } + /// Close a panel. /// Returns true when a bonsplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { +#if DEBUG + let mappedTabIdBeforeClose = surfaceIdFromPanelId(panelId) + dlog( + "surface.close.request panel=\(panelId.uuidString.prefix(5)) " + + "force=\(force ? 1 : 0) mappedTab=\(mappedTabIdBeforeClose.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + + "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + + "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil") " + + "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" + ) +#endif if let tabId = surfaceIdFromPanelId(panelId) { if force { forceCloseTabIds.insert(tabId) } // Close the tab in bonsplit (this triggers delegate callback) - return bonsplitController.closeTab(tabId) + let closed = bonsplitController.closeTab(tabId) +#if DEBUG + dlog( + "surface.close.request.done panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(String(describing: tabId).prefix(5)) closed=\(closed ? 1 : 0) force=\(force ? 1 : 0)" + ) +#endif + return closed } // Mapping can transiently drift during split-tree mutations. If the target panel is - // currently focused, close whichever tab bonsplit marks selected in that focused pane. - guard focusedPanelId == panelId, + // currently focused (or is the active terminal first responder), close whichever tab + // bonsplit marks selected in that focused pane. + let firstResponderPanelId = cmuxOwningGhosttyView( + for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder + )?.terminalSurface?.id + let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId + guard targetIsActive, let focusedPane = bonsplitController.focusedPaneId, let selected = bonsplitController.selectedTab(inPane: focusedPane) else { +#if DEBUG + dlog( + "surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " + + "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + + "firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " + + "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")" + ) +#endif return false } if force { forceCloseTabIds.insert(selected.id) } - return bonsplitController.closeTab(selected.id) + let closed = bonsplitController.closeTab(selected.id) +#if DEBUG + dlog( + "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + + "selectedTab=\(String(describing: selected.id).prefix(5)) " + + "closed=\(closed ? 1 : 0) " + + "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" + ) +#endif + return closed } +#if DEBUG + private func debugPanelLifecycleState(panelId: UUID, panel: (any Panel)?) -> String { + guard let panel else { return "panelState=missing" } + if let terminal = panel as? TerminalPanel { + let hosted = terminal.hostedView + let frame = String(format: "%.1fx%.1f", hosted.frame.width, hosted.frame.height) + let bounds = String(format: "%.1fx%.1f", hosted.bounds.width, hosted.bounds.height) + let hasRuntimeSurface = terminal.surface.surface != nil ? 1 : 0 + return + "panelState=terminal panel=\(panelId.uuidString.prefix(5)) " + + "surface=\(terminal.id.uuidString.prefix(5)) runtimeSurface=\(hasRuntimeSurface) " + + "inWindow=\(hosted.window != nil ? 1 : 0) hasSuperview=\(hosted.superview != nil ? 1 : 0) " + + "hidden=\(hosted.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)" + } + if let browser = panel as? BrowserPanel { + let webView = browser.webView + let frame = String(format: "%.1fx%.1f", webView.frame.width, webView.frame.height) + return + "panelState=browser panel=\(panelId.uuidString.prefix(5)) " + + "webInWindow=\(webView.window != nil ? 1 : 0) webHasSuperview=\(webView.superview != nil ? 1 : 0) frame=\(frame)" + } + return "panelState=\(String(describing: type(of: panel))) panel=\(panelId.uuidString.prefix(5))" + } +#endif + func paneId(forPanelId panelId: UUID) -> PaneID? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } return bonsplitController.allPaneIds.first { paneId in @@ -1255,6 +2601,49 @@ final class Workspace: Identifiable, ObservableObject { return nil } + /// Returns the top-right pane in the current split tree. + /// When a workspace is already split, sidebar PR opens should reuse an existing pane + /// instead of creating additional right splits. + func topRightBrowserReusePane() -> PaneID? { + let paneIds = bonsplitController.allPaneIds + guard paneIds.count > 1 else { return nil } + + let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) + var paneBounds: [String: CGRect] = [:] + browserCollectNormalizedPaneBounds( + node: bonsplitController.treeSnapshot(), + availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), + into: &paneBounds + ) + + guard !paneBounds.isEmpty else { + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + } + + let epsilon = 0.000_1 + let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0 + + let sortedCandidates = paneBounds + .filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon } + .sorted { lhs, rhs in + if abs(lhs.value.minY - rhs.value.minY) > epsilon { + return lhs.value.minY < rhs.value.minY + } + if abs(lhs.value.minX - rhs.value.minX) > epsilon { + return lhs.value.minX > rhs.value.minX + } + return lhs.key < rhs.key + } + + for candidate in sortedCandidates { + if let pane = paneById[candidate.key] { + return pane + } + } + + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + } + private enum BrowserPaneBranch { case first case second @@ -1292,6 +2681,54 @@ final class Workspace: Identifiable, ObservableObject { } } + private func browserCollectNormalizedPaneBounds( + node: ExternalTreeNode, + availableRect: CGRect, + into output: inout [String: CGRect] + ) { + switch node { + case .pane(let paneNode): + output[paneNode.id] = availableRect + case .split(let splitNode): + let divider = min(max(splitNode.dividerPosition, 0), 1) + let firstRect: CGRect + let secondRect: CGRect + + if splitNode.orientation.lowercased() == "vertical" { + // Stacked split: first = top, second = bottom + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width, + height: availableRect.height * divider + ) + secondRect = CGRect( + x: availableRect.minX, + y: availableRect.minY + (availableRect.height * divider), + width: availableRect.width, + height: availableRect.height * (1 - divider) + ) + } else { + // Side-by-side split: first = left, second = right + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width * divider, + height: availableRect.height + ) + secondRect = CGRect( + x: availableRect.minX + (availableRect.width * divider), + y: availableRect.minY, + width: availableRect.width * (1 - divider), + height: availableRect.height + ) + } + + browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output) + browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output) + } + } + private struct BrowserCloseFallbackPlan { let orientation: SplitOrientation let insertFirst: Bool @@ -1435,17 +2872,41 @@ final class Workspace: Identifiable, ObservableObject { func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard panels[panelId] != nil else { return nil } +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime + dlog( + "split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " + + "pendingDetached=\(pendingDetachedSurfaces.count)" + ) +#endif detachingTabIds.insert(tabId) forceCloseTabIds.insert(tabId) + activeDetachCloseTransactions += 1 + defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } guard bonsplitController.closeTab(tabId) else { detachingTabIds.remove(tabId) pendingDetachedSurfaces.removeValue(forKey: tabId) forceCloseTabIds.remove(tabId) +#if DEBUG + dlog( + "split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))" + ) +#endif return nil } - return pendingDetachedSurfaces.removeValue(forKey: tabId) + let detached = pendingDetachedSurfaces.removeValue(forKey: tabId) +#if DEBUG + dlog( + "split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " + + "elapsedMs=\(debugElapsedMs(since: detachStart))" + ) +#endif + return detached } @discardableResult @@ -1455,8 +2916,31 @@ final class Workspace: Identifiable, ObservableObject { atIndex index: Int? = nil, focus: Bool = true ) -> UUID? { - guard bonsplitController.allPaneIds.contains(paneId) else { return nil } - guard panels[detached.panelId] == nil else { return nil } +#if DEBUG + let attachStart = ProcessInfo.processInfo.systemUptime + dlog( + "split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)" + ) +#endif + guard bonsplitController.allPaneIds.contains(paneId) else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } + guard panels[detached.panelId] == nil else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } panels[detached.panelId] = detached.panel if let terminalPanel = detached.panel as? TerminalPanel { @@ -1507,6 +2991,12 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadPanelIds.remove(detached.panelId) manualUnreadMarkedAt.removeValue(forKey: detached.panelId) panelSubscriptions.removeValue(forKey: detached.panelId) +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif return nil } @@ -1528,15 +3018,118 @@ final class Workspace: Identifiable, ObservableObject { } scheduleTerminalGeometryReconcile() +#if DEBUG + dlog( + "split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " + + "index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " + + "elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif return detached.panelId } // MARK: - Focus Management - func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { + private func preserveFocusAfterNonFocusSplit( + preferredPanelId: UUID?, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView? + ) { + guard let preferredPanelId, panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert() + scheduleFocusReconcile() + return + } + + let generation = beginNonFocusSplitFocusReassert( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + + // Bonsplit splitPane focuses the newly created pane and may emit one delayed + // didSelect/didFocus callback. Re-assert focus over multiple turns so model + // focus and AppKit first responder stay aligned with non-focus-intent splits. + reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: true + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + self.scheduleFocusReconcile() + self.clearNonFocusSplitFocusReassert(generation: generation) + } + } + } + + private func reassertFocusAfterNonFocusSplit( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView?, + allowPreviousHostedView: Bool + ) { + guard matchesPendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) else { + return + } + + guard panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert(generation: generation) + return + } + + if focusedPanelId == splitPanelId { + focusPanel( + preferredPanelId, + previousHostedView: allowPreviousHostedView ? previousHostedView : nil + ) + return + } + + guard focusedPanelId == preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) else { + return + } + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) + } + + func focusPanel( + _ panelId: UUID, + previousHostedView: GhosttySurfaceScrollView? = nil, + trigger: FocusPanelTrigger = .standard + ) { + markExplicitFocusIntent(on: panelId) #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" - dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)") - FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)") + let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard" + dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)") + FocusLogStore.shared.append( + "Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)" + ) #endif guard let tabId = surfaceIdFromPanelId(panelId) else { return } let currentlyFocusedPanelId = focusedPanelId @@ -1559,32 +3152,119 @@ final class Workspace: Identifiable, ObservableObject { return bonsplitController.focusedPaneId == targetPaneId && bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId }() + let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged +#if DEBUG + let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let selectedTabShort = bonsplitController.focusedPaneId + .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" + let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.panel.begin workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " + + "targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " + + "converged=\(selectionAlreadyConverged ? 1 : 0) " + + "currentPanel=\(currentPanelShort)" + ) + if shouldSuppressReentrantRefocus { + dlog( + "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + + "reason=firstResponderAlreadyConverged" + ) + } +#endif if let targetPaneId, !selectionAlreadyConverged { +#if DEBUG + dlog( + "focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))" + ) +#endif bonsplitController.focusPane(targetPaneId) } if !selectionAlreadyConverged { +#if DEBUG + dlog( + "focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))" + ) +#endif bonsplitController.selectTab(tabId) } - // Also focus the underlying panel - if let panel = panels[panelId] { - if currentlyFocusedPanelId != panelId || !selectionAlreadyConverged { - panel.focus() + if let targetPaneId { + let activationIntent = panels[panelId]?.preferredFocusIntentForActivation() + applyTabSelection( + tabId: tabId, + inPane: targetPaneId, + reassertAppKitFocus: !shouldSuppressReentrantRefocus, + focusIntent: activationIntent, + previousTerminalHostedView: previousTerminalHostedView + ) + } + + if trigger == .terminalFirstResponder, + panels[panelId] is TerminalPanel { + scheduleTerminalFirstResponderReassert(panelId: panelId) + } + } + + /// A terminal click can arrive while AppKit and bonsplit already look converged, which takes + /// the re-entrant focus path and skips the normal explicit `ensureFocus` call. Re-assert focus + /// on the next couple of turns so stale callbacks from split churn can't leave keyboard input + /// attached to the wrong surface (#1147). + private func scheduleTerminalFirstResponderReassert(panelId: UUID, remainingPasses: Int = 2) { + guard remainingPasses > 0 else { return } + DispatchQueue.main.async { [weak self] in + guard let self, + self.focusedPanelId == panelId, + let terminalPanel = self.terminalPanel(for: panelId) else { + return } - if let terminalPanel = panel as? TerminalPanel { - // Avoid re-entrant focus loops when focus was initiated by AppKit first-responder - // (becomeFirstResponder -> onFocus -> focusPanel). - if !terminalPanel.hostedView.isSurfaceViewFirstResponder() { - terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) - } - } + terminalPanel.hostedView.ensureFocus(for: self.id, surfaceId: panelId) + self.scheduleTerminalFirstResponderReassert( + panelId: panelId, + remainingPasses: remainingPasses - 1 + ) } - if let targetPaneId { - applyTabSelection(tabId: tabId, inPane: targetPaneId) + } + + private func maybeAutoFocusBrowserAddressBarOnPanelFocus( + _ browserPanel: BrowserPanel, + trigger: FocusPanelTrigger + ) { + guard trigger == .standard else { return } + guard !isCommandPaletteVisibleForWorkspaceWindow() else { return } + guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return } + guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return } + + _ = browserPanel.requestAddressBarFocus() + NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id) + } + + private func isCommandPaletteVisibleForWorkspaceWindow() -> Bool { + guard let app = AppDelegate.shared else { + return false } + + if let manager = app.tabManagerFor(tabId: id), + 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 } func moveFocus(direction: NavigationDirection) { @@ -1656,17 +3336,60 @@ final class Workspace: Identifiable, ObservableObject { return newTerminalSurface(inPane: focusedPaneId, focus: focus) } + @discardableResult + func clearSplitZoom() -> Bool { + bonsplitController.clearPaneZoom() + } + + @discardableResult + func toggleSplitZoom(panelId: UUID) -> Bool { + let wasSplitZoomed = bonsplitController.isSplitZoomed + guard let paneId = paneId(forPanelId: panelId) else { return false } + guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false } + focusPanel(panelId) + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") + scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: 4) + scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( + remainingPasses: 4, + reason: "workspace.toggleSplitZoom" + ) + scheduleTerminalGeometryReconcile() + if let browserPanel = browserPanel(for: panelId) { + browserPanel.preparePortalHostReplacementForNextDistinctClaim( + inPane: paneId, + reason: "workspace.toggleSplitZoom" + ) + scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4) + if wasSplitZoomed && !bonsplitController.isSplitZoomed { + scheduleBrowserSplitZoomExitFocusReassert(panelId: panelId, remainingPasses: 4) + } + } + return true + } + + // MARK: - Context Menu Shortcuts + + static func buildContextMenuShortcuts() -> [TabContextAction: KeyboardShortcut] { + var shortcuts: [TabContextAction: KeyboardShortcut] = [:] + let mappings: [(TabContextAction, KeyboardShortcutSettings.Action)] = [ + (.rename, .renameTab), + (.toggleZoom, .toggleSplitZoom), + (.newTerminalToRight, .newSurface), + ] + for (contextAction, settingsAction) in mappings { + let stored = KeyboardShortcutSettings.shortcut(for: settingsAction) + if let key = stored.keyEquivalent { + shortcuts[contextAction] = KeyboardShortcut(key, modifiers: stored.eventModifiers) + } + } + return shortcuts + } + // MARK: - Flash/Notification Support func triggerFocusFlash(panelId: UUID) { - if let terminalPanel = terminalPanel(for: panelId) { - terminalPanel.triggerFlash() - return - } - if let browserPanel = browserPanel(for: panelId) { - browserPanel.triggerFlash() - return - } + panels[panelId]?.triggerFlash() } func triggerNotificationFocusFlash( @@ -1682,7 +3405,7 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerFlash() + terminalPanel.triggerNotificationDismissFlash() } func triggerDebugFlash(panelId: UUID) { @@ -1702,19 +3425,37 @@ final class Workspace: Identifiable, ObservableObject { } } + /// Hide all browser portal views for this workspace. + /// Called before the workspace is unmounted so a portal-hosted WKWebView + /// cannot remain visible after this workspace stops being selected. + func hideAllBrowserPortalViews() { + for panel in panels.values { + guard let browser = panel as? BrowserPanel else { continue } + BrowserWindowPortalRegistry.hide( + webView: browser.webView, + source: "workspaceRetire" + ) + } + } + // MARK: - Utility /// Create a new terminal panel (used when replacing the last panel) @discardableResult func createReplacementTerminalPanel() -> TerminalPanel { + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: focusedPanelId, + inPane: bonsplitController.focusedPaneId + ) let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: nil, + configTemplate: inheritedConfig, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit if let newTabId = bonsplitController.createTab( @@ -1792,11 +3533,17 @@ final class Workspace: Identifiable, ObservableObject { currentDirectory = dir } gitBranch = panelGitBranches[targetPanelId] + pullRequest = panelPullRequests[targetPanelId] } /// Reconcile focus/first-responder convergence. /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. private func scheduleFocusReconcile() { +#if DEBUG + if isDetachingCloseTransaction { + debugFocusReconcileScheduledDuringDetachCount += 1 + } +#endif guard !focusReconcileScheduled else { return } focusReconcileScheduled = true DispatchQueue.main.async { [weak self] in @@ -1808,19 +3555,344 @@ final class Workspace: Identifiable, ObservableObject { /// Reconcile remaining terminal view geometries after split topology changes. /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. + private func reconcileTerminalGeometryPass() -> Bool { + var needsFollowUpPass = false + + // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + } + + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let hostedView = terminalPanel.hostedView + let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 + let hasSurface = terminalPanel.surface.surface != nil + let isAttached = hostedView.window != nil && hostedView.superview != nil + + // Split close/reparent churn can transiently detach a surviving terminal view. + // Force one SwiftUI representable update so the portal binding reattaches it. + if !isAttached || !hasUsableBounds || !hasSurface { + terminalPanel.requestViewReattach() + needsFollowUpPass = true + } + + let geometryChanged = hostedView.reconcileGeometryNow() + // Re-check surface after reconcileGeometryNow() which can trigger AppKit + // layout and view lifecycle changes that free surfaces (#432). + if geometryChanged, terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") + } + if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + needsFollowUpPass = true + } + } + + return needsFollowUpPass + } + + private func runScheduledTerminalGeometryReconcile(remainingPasses: Int) { + guard remainingPasses > 0 else { + geometryReconcileScheduled = false + geometryReconcileNeedsRerun = false + return + } + + let needsFollowUpPass = reconcileTerminalGeometryPass() + let shouldRunAgain = geometryReconcileNeedsRerun || needsFollowUpPass + + if shouldRunAgain, remainingPasses > 1 { + geometryReconcileNeedsRerun = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.runScheduledTerminalGeometryReconcile(remainingPasses: remainingPasses - 1) + } + return + } + + geometryReconcileScheduled = false + geometryReconcileNeedsRerun = false + } + private func scheduleTerminalGeometryReconcile() { - guard !geometryReconcileScheduled else { return } + guard !geometryReconcileScheduled else { + geometryReconcileNeedsRerun = true + return + } geometryReconcileScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } - self.geometryReconcileScheduled = false + self.runScheduledTerminalGeometryReconcile(remainingPasses: 4) + } + } - for panel in self.panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - terminalPanel.hostedView.reconcileGeometryNow() - terminalPanel.surface.forceRefresh() + private func renderedVisiblePanelIdsForCurrentLayout() -> Set<UUID> { + let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds + var visiblePanelIds: Set<UUID> = [] + + for paneId in renderedPaneIds { + let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first + guard let selectedTab, + let panelId = panelIdFromSurfaceId(selectedTab.id), + panels[panelId] != nil else { + continue + } + visiblePanelIds.insert(panelId) + } + + if let focusedPanelId, + panels[focusedPanelId] != nil, + let focusedPaneId = paneId(forPanelId: focusedPanelId), + renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) { + visiblePanelIds.insert(focusedPanelId) + } + + return visiblePanelIds + } + + private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) + terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) + terminalPanel.hostedView.setActive(shouldBeVisible && focusedPanelId == terminalPanel.id) + TerminalWindowPortalRegistry.updateEntryVisibility( + for: terminalPanel.hostedView, + visibleInUI: shouldBeVisible + ) + } + } + + private func terminalPortalVisibilityNeedsFollowUp() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) + let hostedView = terminalPanel.hostedView + + if shouldBeVisible { + if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil { + return true + } + } else if !hostedView.isHidden { + return true } } + + return false + } + + private func scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: Int) { + guard remainingPasses > 0 else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + + self.reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + + if self.terminalPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { + self.scheduleTerminalPortalVisibilityReconcileAfterSplitZoom( + remainingPasses: remainingPasses - 1 + ) + } + } + } + + private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(browserPanel.id) + if shouldBeVisible { + BrowserWindowPortalRegistry.updateEntryVisibility( + for: browserPanel.webView, + visibleInUI: true, + zPriority: 2 + ) + let anchorView = browserPanel.portalAnchorView + let anchorReady = + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + if anchorReady { + BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: reason + ) + } + } else { + BrowserWindowPortalRegistry.updateEntryVisibility( + for: browserPanel.webView, + visibleInUI: false, + zPriority: 0 + ) + BrowserWindowPortalRegistry.hide( + webView: browserPanel.webView, + source: reason + ) + } + } + } + + private func browserPortalVisibilityNeedsFollowUp() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + guard visiblePanelIds.contains(browserPanel.id) else { continue } + let anchorView = browserPanel.portalAnchorView + let anchorReady = + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + if !anchorReady || + browserPanel.webView.window == nil || + browserPanel.webView.superview == nil || + !BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) { + return true + } + } + + return false + } + + private func scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( + remainingPasses: Int, + reason: String + ) { + guard remainingPasses > 0 else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + + self.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) + + if self.browserPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { + self.scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( + remainingPasses: remainingPasses - 1, + reason: reason + ) + } + } + } + + // Browser panes host WKWebView in the window portal. After pane zoom toggles, + // force a few post-layout sync passes so the portal does not outlive the omnibar chrome. + private func scheduleBrowserPortalReconcileAfterSplitZoom(panelId: UUID, remainingPasses: Int) { + guard remainingPasses > 0 else { return } + DispatchQueue.main.async { [weak self] in + guard let self, let browserPanel = self.browserPanel(for: panelId) else { return } + + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + + let anchorView = browserPanel.portalAnchorView + let anchorReady = + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + + if anchorReady { + BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: "workspace.toggleSplitZoom" + ) + } + + let portalNeedsFollowUpPass = + !anchorReady || + browserPanel.webView.window == nil || + browserPanel.webView.superview == nil + if portalNeedsFollowUpPass { + self.scheduleBrowserPortalReconcileAfterSplitZoom( + panelId: panelId, + remainingPasses: remainingPasses - 1 + ) + } + } + } + + // Browser panes can briefly keep the portal-hosted WKWebView visible while Bonsplit is + // still rebuilding the unzoomed pane host. Reassert pane/tab selection after layout settles + // so the SwiftUI chrome does not remain hidden until another browser focus command runs. + private func scheduleBrowserSplitZoomExitFocusReassert(panelId: UUID, remainingPasses: Int) { + guard remainingPasses > 0 else { return } + DispatchQueue.main.async { [weak self] in + guard let self, self.browserPanel(for: panelId) != nil else { return } + guard let paneId = self.paneId(forPanelId: panelId), + let tabId = self.surfaceIdFromPanelId(panelId) else { return } + + let selectionConverged = + self.bonsplitController.focusedPaneId == paneId && + self.bonsplitController.selectedTab(inPane: paneId)?.id == tabId + let anchorReady: Bool = { + guard let browserPanel = self.browserPanel(for: panelId) else { return false } + let anchorView = browserPanel.portalAnchorView + return + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + }() + + if !selectionConverged { + self.focusPanel(panelId) + self.scheduleFocusReconcile() + } + + if !selectionConverged || !anchorReady { + self.scheduleBrowserSplitZoomExitFocusReassert( + panelId: panelId, + remainingPasses: remainingPasses - 1 + ) + } + } + } + + private func scheduleMovedTerminalRefresh(panelId: UUID) { + guard terminalPanel(for: panelId) != nil else { return } + + // Force an NSViewRepresentable update after drag/move reparenting. This keeps + // portal host binding current when a pane auto-closes during tab moves. + terminalPanel(for: panelId)?.requestViewReattach() + + let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + guard let self, let panel = self.terminalPanel(for: panelId) else { return } + let geometryChanged = panel.hostedView.reconcileGeometryNow() + if geometryChanged, panel.surface.surface != nil { + panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh") + } + if panel.surface.surface == nil { + panel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } + } + + // Run once immediately and once on the next turn so rapid split close/reparent + // sequences still get a post-layout redraw. + runRefreshPass(0) + runRefreshPass(0.03) } private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { @@ -1876,15 +3948,15 @@ final class Workspace: Identifiable, ObservableObject { let panel = panels[panelId] else { return } let alert = NSAlert() - alert.messageText = "Rename Tab" - alert.informativeText = "Enter a custom name for this tab." + alert.messageText = String(localized: "dialog.renameTab.title", defaultValue: "Rename Tab") + alert.informativeText = String(localized: "dialog.renameTab.message", defaultValue: "Enter a custom name for this tab.") let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle let input = NSTextField(string: currentTitle) - input.placeholderString = "Tab name" + input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name") input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input - alert.addButton(withTitle: "Rename") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) let alertWindow = alert.window alertWindow.initialFirstResponder = input DispatchQueue.main.async { @@ -1896,6 +3968,147 @@ final class Workspace: Identifiable, ObservableObject { setPanelCustomTitle(panelId: panelId, title: input.stringValue) } + private enum PanelMoveDestination { + case newWorkspaceInCurrentWindow + case selectedWorkspaceInNewWindow + case existingWorkspace(UUID) + } + + private func promptMovePanel(tabId: TabID) { + guard let panelId = panelIdFromSurfaceId(tabId), + let app = AppDelegate.shared else { return } + + let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) } + let workspaceTargets = app.workspaceMoveTargets( + excludingWorkspaceId: id, + referenceWindowId: currentWindowId + ) + + var options: [(title: String, destination: PanelMoveDestination)] = [ + (String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), + (String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), + ] + options.append(contentsOf: workspaceTargets.map { target in + (target.label, .existingWorkspace(target.workspaceId)) + }) + + let alert = NSAlert() + alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab") + alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "Choose a destination for this tab.") + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) + for option in options { + popup.addItem(withTitle: option.title) + } + popup.selectItem(at: 0) + alert.accessoryView = popup + alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + guard alert.runModal() == .alertFirstButtonReturn else { return } + let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) + let destination = options[selectedIndex].destination + + let moved: Bool + switch destination { + case .newWorkspaceInCurrentWindow: + guard let manager = app.tabManagerFor(tabId: id) else { return } + let workspace = manager.addWorkspace(select: true) + moved = app.moveSurface( + panelId: panelId, + toWorkspace: workspace.id, + focus: true, + focusWindow: false + ) + + case .selectedWorkspaceInNewWindow: + let newWindowId = app.createMainWindow() + guard let destinationManager = app.tabManagerFor(windowId: newWindowId), + let destinationWorkspaceId = destinationManager.selectedTabId else { + return + } + moved = app.moveSurface( + panelId: panelId, + toWorkspace: destinationWorkspaceId, + focus: true, + focusWindow: true + ) + if !moved { + _ = app.closeMainWindow(windowId: newWindowId) + } + + case .existingWorkspace(let workspaceId): + moved = app.moveSurface( + panelId: panelId, + toWorkspace: workspaceId, + focus: true, + focusWindow: true + ) + } + + if !moved { + let failure = NSAlert() + failure.alertStyle = .warning + failure.messageText = String(localized: "dialog.moveFailed.title", defaultValue: "Move Failed") + failure.informativeText = String(localized: "dialog.moveFailed.message", defaultValue: "cmux could not move this tab to the selected destination.") + failure.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + _ = failure.runModal() + } + } + + private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { + guard let app = AppDelegate.shared else { return false } +#if DEBUG + let dropStart = ProcessInfo.processInfo.systemUptime +#endif + + let targetPane: PaneID + let targetIndex: Int? + let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? +#if DEBUG + let destinationLabel: String +#endif + + switch request.destination { + case .insert(let paneId, let index): + targetPane = paneId + targetIndex = index + splitTarget = nil +#if DEBUG + destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")" +#endif + case .split(let paneId, let orientation, let insertFirst): + targetPane = paneId + targetIndex = nil + splitTarget = (orientation, insertFirst) +#if DEBUG + destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)" +#endif + } + + #if DEBUG + dlog( + "split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)" + ) + #endif + let moved = app.moveBonsplitTab( + tabId: request.tabId.uuid, + toWorkspace: id, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: true, + focusWindow: true + ) +#if DEBUG + dlog( + "split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))" + ) +#endif + return moved + } + } // MARK: - BonsplitDelegate @@ -1904,11 +4117,11 @@ extension Workspace: BonsplitDelegate { @MainActor private func confirmClosePanel(for tabId: TabID) async -> Bool { let alert = NSAlert() - alert.messageText = "Close tab?" - alert.informativeText = "This will close the current tab." + alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") + alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") alert.alertStyle = .warning - alert.addButton(withTitle: "Close") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) // Prefer a sheet if we can find a window, otherwise fall back to modal. if let window = NSApp.keyWindow ?? NSApp.mainWindow { @@ -1924,8 +4137,20 @@ extension Workspace: BonsplitDelegate { /// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state). /// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab). - private func applyTabSelection(tabId: TabID, inPane pane: PaneID) { - pendingTabSelection = (tabId: tabId, pane: pane) + private func applyTabSelection( + tabId: TabID, + inPane pane: PaneID, + reassertAppKitFocus: Bool = true, + focusIntent: PanelFocusIntent? = nil, + previousTerminalHostedView: GhosttySurfaceScrollView? = nil + ) { + pendingTabSelection = PendingTabSelectionRequest( + tabId: tabId, + pane: pane, + reassertAppKitFocus: reassertAppKitFocus, + focusIntent: focusIntent, + previousTerminalHostedView: previousTerminalHostedView + ) guard !isApplyingTabSelection else { return } isApplyingTabSelection = true defer { @@ -1938,12 +4163,36 @@ extension Workspace: BonsplitDelegate { pendingTabSelection = nil iterations += 1 if iterations > 8 { break } - applyTabSelectionNow(tabId: request.tabId, inPane: request.pane) + applyTabSelectionNow( + tabId: request.tabId, + inPane: request.pane, + reassertAppKitFocus: request.reassertAppKitFocus, + focusIntent: request.focusIntent, + previousTerminalHostedView: request.previousTerminalHostedView + ) } } - private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) { + private func applyTabSelectionNow( + tabId: TabID, + inPane pane: PaneID, + reassertAppKitFocus: Bool, + focusIntent: PanelFocusIntent?, + previousTerminalHostedView: GhosttySurfaceScrollView? + ) { let previousFocusedPanelId = focusedPanelId +#if DEBUG + let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let selectedTabBefore = bonsplitController.focusedPaneId + .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " + + "pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " + + "focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " + + "reassert=\(reassertAppKitFocus ? 1 : 0)" + ) +#endif if bonsplitController.allPaneIds.contains(pane) { if bonsplitController.focusedPaneId != pane { bonsplitController.focusPane(pane) @@ -1969,20 +4218,64 @@ extension Workspace: BonsplitDelegate { return } - // Focus the selected panel - guard let panelId = panelIdFromSurfaceId(selectedTabId), - let panel = panels[panelId] else { + // Focus the selected panel, but keep the previously focused terminal active while a + // newly created split terminal is still unattached. + guard let selectedPanelId = panelIdFromSurfaceId(selectedTabId) else { return } - syncPinnedStateForTab(selectedTabId, panelId: panelId) - syncUnreadBadgeStateForPanel(panelId) + let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId + guard let panel = panels[effectiveFocusedPanelId] else { + return + } + + if debugStressPreloadSelectionDepth > 0 { + if let terminalPanel = panel as? TerminalPanel { + terminalPanel.requestViewReattach() + scheduleTerminalGeometryReconcile() + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + return + } + + if shouldTreatCurrentEventAsExplicitFocusIntent() { + markExplicitFocusIntent(on: effectiveFocusedPanelId) + } + let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() + panel.prepareFocusIntentForActivation(activationIntent) + let panelId = effectiveFocusedPanelId + + syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId) + syncUnreadBadgeStateForPanel(selectedPanelId) // Unfocus all other panels - for (id, p) in panels where id != panelId { + for (id, p) in panels where id != effectiveFocusedPanelId { p.unfocus() } - panel.focus() + if let focusWindow = activationWindow(for: panel) { + yieldForeignOwnedFocusIfNeeded( + in: focusWindow, + targetPanelId: panelId, + targetIntent: activationIntent + ) + } + + activatePanel( + panel, + focusIntent: activationIntent, + reassertAppKitFocus: reassertAppKitFocus + ) + let focusIntentAllowsBrowserOmnibarAutofocus = + shouldTreatCurrentEventAsExplicitFocusIntent() || + TerminalController.socketCommandAllowsInAppFocusMutations() + if let browserPanel = panel as? BrowserPanel, + shouldAllowBrowserOmnibarAutofocus(for: activationIntent), + previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard) + } + if let terminalPanel = panel as? TerminalPanel { + rememberTerminalConfigInheritanceSource(terminalPanel) + } let isManuallyUnread = manualUnreadPanelIds.contains(panelId) let markedAt = manualUnreadMarkedAt[panelId] if Self.shouldClearManualUnread( @@ -2004,15 +4297,39 @@ extension Workspace: BonsplitDelegate { // Converge AppKit first responder with bonsplit's selected tab in the focused pane. // Without this, keyboard input can remain on a different terminal than the blue tab indicator. - if let terminalPanel = panel as? TerminalPanel { + if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel { + if shouldMoveTerminalSurfaceFocus(for: activationIntent), + !terminalPanel.hostedView.isSurfaceViewFirstResponder() { +#if DEBUG + let previousExists = previousTerminalHostedView != nil ? 1 : 0 + dlog( + "focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " + + "to=\(panelId.uuidString.prefix(5))" + ) +#endif + terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) + } +#if DEBUG + dlog( + "focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " + + "tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))" + ) +#endif terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId) } + if shouldRestoreFocusIntentAfterActivation(activationIntent) { + _ = panel.restoreFocusIntent(activationIntent) + } + // Update current directory if this is a terminal if let dir = panelDirectories[panelId] { currentDirectory = dir } gitBranch = panelGitBranches[panelId] + pullRequest = panelPullRequests[panelId] // Post notification NotificationCenter.default.post( @@ -2023,6 +4340,159 @@ extension Workspace: BonsplitDelegate { GhosttyNotificationKey.surfaceId: panelId ] ) +#if DEBUG + let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " + + "focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " + + "prevPanel=\(prevPanelShort)" + ) +#endif + } + + private func activatePanel( + _ panel: any Panel, + focusIntent: PanelFocusIntent, + reassertAppKitFocus: Bool + ) { + if let terminalPanel = panel as? TerminalPanel { + let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent) + terminalPanel.surface.setFocus(shouldFocusTerminalSurface) + terminalPanel.hostedView.setActive(true) + if reassertAppKitFocus && shouldFocusTerminalSurface { + terminalPanel.focus() + } + return + } + + if let browserPanel = panel as? BrowserPanel { + guard shouldFocusBrowserWebView(for: focusIntent) else { return } + browserPanel.focus() + return + } + + if reassertAppKitFocus { + panel.focus() + } + } + + private func activationWindow(for panel: any Panel) -> NSWindow? { + if let terminalPanel = panel as? TerminalPanel { + return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + if let browserPanel = panel as? BrowserPanel { + return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + return NSApp.keyWindow ?? NSApp.mainWindow + } + + private func yieldForeignOwnedFocusIfNeeded( + in window: NSWindow, + targetPanelId: UUID, + targetIntent: PanelFocusIntent + ) { + guard let firstResponder = window.firstResponder else { return } + + for (panelId, panel) in panels where panelId != targetPanelId { + guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue } +#if DEBUG + dlog( + "focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " + + "fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " + + "fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))" + ) +#endif + _ = panel.yieldFocusIntent(ownedIntent, in: window) + return + } + } + + private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .terminal(.findField): + return false + default: + return true + } + } + + private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.addressBar), .browser(.findField): + return false + default: + return true + } + } + + private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.webView), .panel: + return true + default: + return false + } + } + + private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.addressBar), .browser(.findField), .terminal(.findField): + return true + case .panel, .browser(.webView), .terminal(.surface): + return false + } + } + + private func beginNonFocusSplitFocusReassert( + preferredPanelId: UUID, + splitPanelId: UUID + ) -> UInt64 { + nonFocusSplitFocusReassertGeneration &+= 1 + let generation = nonFocusSplitFocusReassertGeneration + pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + return generation + } + + private func matchesPendingNonFocusSplitFocusReassert( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID + ) -> Bool { + guard let pending = pendingNonFocusSplitFocusReassert else { return false } + return pending.generation == generation && + pending.preferredPanelId == preferredPanelId && + pending.splitPanelId == splitPanelId + } + + private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { + guard let pending = pendingNonFocusSplitFocusReassert else { return } + if let generation, pending.generation != generation { return } + pendingNonFocusSplitFocusReassert = nil + } + + private func shouldTreatCurrentEventAsExplicitFocusIntent() -> Bool { + guard let eventType = NSApp.currentEvent?.type else { return false } + switch eventType { + case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, + .otherMouseDown, .otherMouseUp, .keyDown, .keyUp, .scrollWheel, + .gesture, .magnify, .rotate, .swipe: + return true + default: + return false + } + } + + private func markExplicitFocusIntent(on panelId: UUID) { + guard let pending = pendingNonFocusSplitFocusReassert, + pending.splitPanelId == panelId else { + return + } + pendingNonFocusSplitFocusReassert = nil } func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { @@ -2106,37 +4576,49 @@ extension Workspace: BonsplitDelegate { forceCloseTabIds.remove(tabId) let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId) let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) + let isDetaching = detachingTabIds.remove(tabId) != nil || isDetachingCloseTransaction // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { #if DEBUG - NSLog("[Workspace] didCloseTab: no panelId for tabId") + dlog( + "surface.didCloseTab.skip tab=\(String(describing: tabId).prefix(5)) " + + "pane=\(pane.id.uuidString.prefix(5)) reason=missingPanelMapping " + + "panels=\(panels.count) panes=\(controller.allPaneIds.count)" + ) #endif scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetaching { + scheduleFocusReconcile() + } return } - #if DEBUG - NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") - #endif - - let isDetaching = detachingTabIds.remove(tabId) != nil let panel = panels[panelId] +#if DEBUG + dlog( + "surface.didCloseTab.begin tab=\(String(describing: tabId).prefix(5)) " + + "pane=\(pane.id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "isDetaching=\(isDetaching ? 1 : 0) selectAfter=\(selectTabId.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + + "\(debugPanelLifecycleState(panelId: panelId, panel: panel))" + ) +#endif if isDetaching, let panel { let browserPanel = panel as? BrowserPanel + let cachedTitle = panelTitles[panelId] + let transferFallbackTitle = cachedTitle ?? panel.displayTitle pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, - title: resolvedPanelTitle(panelId: panelId, fallback: panel.displayTitle), + title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, kind: surfaceKind(for: panel), isLoading: browserPanel?.isLoading ?? false, isPinned: pinnedPanelIds.contains(panelId), directory: panelDirectories[panelId], - cachedTitle: panelTitles[panelId], + cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], manuallyUnread: manualUnreadPanelIds.contains(panelId) ) @@ -2151,6 +4633,7 @@ extension Workspace: BonsplitDelegate { surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) + panelPullRequests.removeValue(forKey: panelId) panelTitles.removeValue(forKey: panelId) panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) @@ -2158,11 +4641,28 @@ extension Workspace: BonsplitDelegate { manualUnreadMarkedAt.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) + if lastTerminalConfigInheritancePanelId == panelId { + lastTerminalConfigInheritancePanelId = nil + } - // Keep the workspace invariant: always retain at least one real panel. - // This prevents runtime close callbacks from ever collapsing into a tabless workspace. + // Keep the workspace invariant for normal close paths. + // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can + // prune the source workspace/window after the tab is attached elsewhere. if panels.isEmpty { + if isDetaching { +#if DEBUG + dlog( + "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) mode=detachingEmptyWorkspace" + ) +#endif + scheduleTerminalGeometryReconcile() + return + } + let replacement = createReplacementTerminalPanel() if let replacementTabId = surfaceIdFromPanelId(replacement.id), let replacementPane = bonsplitController.allPaneIds.first { @@ -2172,6 +4672,13 @@ extension Workspace: BonsplitDelegate { } scheduleTerminalGeometryReconcile() scheduleFocusReconcile() +#if DEBUG + dlog( + "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) mode=replacementCreated " + + "replacement=\(replacement.id.uuidString.prefix(5)) panels=\(panels.count)" + ) +#endif return } @@ -2193,8 +4700,19 @@ extension Workspace: BonsplitDelegate { if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } +#if DEBUG + let focusedPaneAfter = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" + dlog( + "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) panes=\(controller.allPaneIds.count) " + + "focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter)" + ) +#endif scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetaching { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { @@ -2203,18 +4721,56 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { #if DEBUG - let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown" + let now = ProcessInfo.processInfo.systemUptime + let sincePrev: String + if debugLastDidMoveTabTimestamp > 0 { + sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000) + } else { + sincePrev = "first" + } + debugLastDidMoveTabTimestamp = now + debugDidMoveTabEventCount += 1 + let movedPanelId = panelIdFromSurfaceId(tab.id) + let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown" + let selectedBefore = controller.selectedTab(inPane: destination) + .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" + let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil" dlog( - "split.moveTab panel=\(movedPanel) " + + "split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " + "from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " + "sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)" ) + dlog( + "split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)" + ) #endif applyTabSelection(tabId: tab.id, inPane: destination) +#if DEBUG + let movedPanelIdAfter = panelIdFromSurfaceId(tab.id) +#endif + if let movedPanelId = panelIdFromSurfaceId(tab.id) { + scheduleMovedTerminalRefresh(panelId: movedPanelId) + } +#if DEBUG + let selectedAfter = controller.selectedTab(inPane: destination) + .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" + let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" + let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0 + dlog( + "split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " + + "movedFocused=\(movedPanelFocused)" + ) +#endif normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { @@ -2236,13 +4792,27 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] + let shouldScheduleFocusReconcile = !isDetachingCloseTransaction +#if DEBUG + dlog( + "surface.didClosePane.begin pane=\(paneId.id.uuidString.prefix(5)) " + + "closedPanels=\(closedPanelIds.count) detaching=\(isDetachingCloseTransaction ? 1 : 0)" + ) +#endif if !closedPanelIds.isEmpty { for panelId in closedPanelIds { +#if DEBUG + dlog( + "surface.didClosePane.panel pane=\(paneId.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) \(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" + ) +#endif panels[panelId]?.close() panels.removeValue(forKey: panelId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) + panelPullRequests.removeValue(forKey: panelId) panelTitles.removeValue(forKey: panelId) panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) @@ -2250,6 +4820,7 @@ extension Workspace: BonsplitDelegate { panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) } @@ -2260,13 +4831,21 @@ extension Workspace: BonsplitDelegate { if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { applyTabSelection(tabId: focusedTabId, inPane: focusedPane) - } else { + } else if shouldScheduleFocusReconcile { scheduleFocusReconcile() } } scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if shouldScheduleFocusReconcile { + scheduleFocusReconcile() + } +#if DEBUG + dlog( + "surface.didClosePane.end pane=\(paneId.id.uuidString.prefix(5)) " + + "remainingPanels=\(panels.count) remainingPanes=\(bonsplitController.allPaneIds.count)" + ) +#endif } func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { @@ -2311,6 +4890,21 @@ extension Workspace: BonsplitDelegate { "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" ) #endif + let rearmBrowserPortalHostReplacement: (PaneID, String) -> Void = { paneId, reason in + for tab in controller.tabs(inPane: paneId) { + guard let panelId = self.panelIdFromSurfaceId(tab.id), + let browserPanel = self.browserPanel(for: panelId) else { + continue + } + browserPanel.preparePortalHostReplacementForNextDistinctClaim( + inPane: paneId, + reason: reason + ) + } + } + rearmBrowserPortalHostReplacement(originalPane, "workspace.didSplit.original") + rearmBrowserPortalHostReplacement(newPane, "workspace.didSplit.new") + // Only auto-create a terminal if the split came from bonsplit UI. // Programmatic splits via newTerminalSplit() set isProgrammaticSplit and handle their own panels. guard !isProgrammaticSplit else { @@ -2351,15 +4945,7 @@ extension Workspace: BonsplitDelegate { // Keep the existing placeholder tab identity and replace only the panel mapping. // This avoids an extra create+close tab churn that can transiently render an // empty pane during drag-to-split of a single-tab pane. - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: originalPane) let replacementPanel = TerminalPanel( workspaceId: id, @@ -2369,6 +4955,7 @@ extension Workspace: BonsplitDelegate { ) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) surfaceIdToPanelId[replacementTab.id] = replacementPanel.id bonsplitController.updateTab( @@ -2408,23 +4995,23 @@ extension Workspace: BonsplitDelegate { return } - // Get the focused terminal in the original pane to inherit config from - guard let sourceTabId = controller.selectedTab(inPane: originalPane)?.id, - let sourcePanelId = panelIdFromSurfaceId(sourceTabId), - let sourcePanel = terminalPanel(for: sourcePanelId) else { return } + // Mirror Cmd+D behavior: split buttons should always seed a terminal in the new pane. + // When the focused source is a browser, inherit terminal config from nearby terminals + // (or fall back to defaults) instead of leaving an empty selector pane. + let sourceTabId = controller.selectedTab(inPane: originalPane)?.id + let sourcePanelId = sourceTabId.flatMap { panelIdFromSurfaceId($0) } #if DEBUG dlog( "split.didSplit.autoCreate pane=\(newPane.id.uuidString.prefix(5)) " + - "fromPane=\(originalPane.id.uuidString.prefix(5)) sourcePanel=\(sourcePanelId.uuidString.prefix(5))" + "fromPane=\(originalPane.id.uuidString.prefix(5)) sourcePanel=\(sourcePanelId.map { String($0.uuidString.prefix(5)) } ?? "none")" ) #endif - let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface { - ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } else { - nil - } + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: sourcePanelId, + inPane: originalPane + ) let newPanel = TerminalPanel( workspaceId: id, @@ -2434,6 +5021,7 @@ extension Workspace: BonsplitDelegate { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, @@ -2445,6 +5033,7 @@ extension Workspace: BonsplitDelegate { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return } @@ -2493,6 +5082,8 @@ extension Workspace: BonsplitDelegate { closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) case .closeOthers: closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) + case .move: + promptMovePanel(tabId: tab.id) case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: @@ -2507,9 +5098,15 @@ extension Workspace: BonsplitDelegate { guard let panelId = panelIdFromSurfaceId(tab.id) else { return } let shouldPin = !pinnedPanelIds.contains(panelId) setPanelPinned(panelId: panelId, pinned: shouldPin) + case .markAsRead: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + clearManualUnread(panelId: panelId) case .markAsUnread: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } markPanelUnread(panelId) + case .toggleZoom: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + toggleSplitZoom(panelId: panelId) @unknown default: break } @@ -2518,7 +5115,9 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { _ = snapshot scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } } // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index defce523..0b955943 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -9,10 +9,27 @@ struct WorkspaceContentView: View { let isWorkspaceVisible: Bool let isWorkspaceInputActive: Bool let workspacePortalPriority: Int - @State private var config = GhosttyConfig.load() + let onThemeRefreshRequest: (( + _ reason: String, + _ backgroundEventId: UInt64?, + _ backgroundSource: String?, + _ notificationPayloadHex: String? + ) -> Void)? + @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore + static func panelVisibleInUI( + isWorkspaceVisible: Bool, + isSelectedInPane: Bool, + isFocused: Bool + ) -> Bool { + guard isWorkspaceVisible else { return false } + // During pane/tab reparenting, Bonsplit can transiently report selected=false + // for the currently focused panel. Keep focused content visible to avoid blank frames. + return isSelectedInPane || isFocused + } + var body: some View { let appearance = PanelAppearance.fromConfig(config) let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || @@ -41,13 +58,18 @@ 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 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, + paneId: paneId, isFocused: isFocused, isSelectedInPane: isSelectedInPane, isVisibleInUI: isVisibleInUI, @@ -61,7 +83,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,10 +106,15 @@ struct WorkspaceContentView: View { workspace.bonsplitController.focusPane(paneId) } } + .internalOnlyTabDrag() + // Split zoom swaps Bonsplit between the full split tree and a single pane view. + // Recreate the Bonsplit subtree on zoom enter/exit so stale pre-zoom pane chrome + // cannot remain stacked above portal-hosted browser content. + .id(splitZoomRenderIdentity) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { syncBonsplitNotificationBadges() - workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) + refreshGhosttyAppearanceConfig(reason: "onAppear") } .onChange(of: notificationStore.notifications) { _, _ in syncBonsplitNotificationBadges() @@ -96,18 +123,29 @@ struct WorkspaceContentView: View { syncBonsplitNotificationBadges() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in - refreshGhosttyAppearanceConfig() + GhosttyConfig.invalidateLoadCache() + refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload") } - .onChange(of: colorScheme) { _, _ in + .onChange(of: colorScheme) { oldValue, newValue in // Keep split overlay color/opacity in sync with light/dark theme transitions. - refreshGhosttyAppearanceConfig() + refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)") } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in - if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor { - workspace.applyGhosttyChrome(backgroundColor: backgroundColor) - } else { - workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) - } + let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" + let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value + let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil" + logTheme( + "theme notification workspace=\(workspace.id.uuidString) event=\(eventId.map(String.init) ?? "nil") source=\(source) payload=\(payloadHex) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) + // Payload ordering can lag across rapid config/theme updates. + // Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned + // with Ghostty's current runtime theme. + refreshGhosttyAppearanceConfig( + reason: "ghosttyDefaultBackgroundDidChange", + backgroundEventId: eventId, + backgroundSource: source, + notificationPayloadHex: payloadHex + ) } } @@ -141,10 +179,104 @@ struct WorkspaceContentView: View { } } - private func refreshGhosttyAppearanceConfig() { - let next = GhosttyConfig.load() - config = next - workspace.applyGhosttyChrome(from: next) + private var splitZoomRenderIdentity: String { + workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed" + } + + static func resolveGhosttyAppearanceConfig( + reason: String = "unspecified", + backgroundOverride: NSColor? = nil, + loadConfig: () -> GhosttyConfig = { GhosttyConfig.load() }, + defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }, + defaultBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity } + ) -> 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 + // Use the runtime opacity from the Ghostty engine, which may differ from the + // file-level value parsed by GhosttyConfig.load(). + next.backgroundOpacity = defaultBackgroundOpacity() + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) opacity=\(String(format: "%.3f", next.backgroundOpacity)) 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 opacityChanged = abs(config.backgroundOpacity - next.backgroundOpacity) > 0.0001 + let shouldRequestTitlebarRefresh = backgroundChanged || opacityChanged || 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) } } @@ -177,6 +309,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 @@ -211,6 +345,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") @@ -222,31 +399,23 @@ 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) - .background(Color(nsColor: .windowBackgroundColor)) + .background(Color(nsColor: GhosttyBackgroundTheme.currentColor())) #if DEBUG .onAppear { DebugUIEventCounters.emptyPanelAppearCount += 1 diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 5f200bc4..69523c74 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1,6 +1,8 @@ import AppKit import SwiftUI import Darwin +import Bonsplit +import UniformTypeIdentifiers @main struct cmuxApp: App { @@ -12,7 +14,18 @@ struct cmuxApp: App { @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue + @AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.newWindow.defaultsKey) private var newWindowShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.showNotifications.defaultsKey) private var showNotificationsShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.nextSurface.defaultsKey) private var nextSurfaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.prevSurface.defaultsKey) private var prevSurfaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey) private var nextWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) @@ -21,11 +34,21 @@ struct cmuxApp: App { private var showBrowserJavaScriptConsoleShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.openFolder.defaultsKey) private var openFolderShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { + if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { + Self.terminateForMissingLaunchTag() + } + Self.configureGhosttyEnvironment() + // Apply saved language preference before any UI loads + LanguageSettings.apply(LanguageSettings.languageAtLaunch) + let startupAppearance = AppearanceSettings.resolvedMode() Self.applyAppearance(startupAppearance) _tabManager = StateObject(wrappedValue: TabManager()) @@ -40,6 +63,15 @@ struct cmuxApp: App { defaults.set(legacy ? SocketControlMode.cmuxOnly.rawValue : SocketControlMode.off.rawValue, forKey: SocketControlSettings.appStorageKey) } + // Skip keychain migration for DEV/staging builds. Each tagged build gets a + // unique bundle ID with its own UserDefaults domain, so migration would run + // on every launch and trigger a macOS keychain access prompt (the legacy + // keychain item was created by a differently-signed app). + let bundleID = Bundle.main.bundleIdentifier + if !SocketControlSettings.isDebugLikeBundleIdentifier(bundleID) + && !SocketControlSettings.isStagingBundleIdentifier(bundleID) { + SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(defaults: defaults) + } migrateSidebarAppearanceDefaultsIfNeeded(defaults: defaults) // UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance @@ -47,6 +79,14 @@ struct cmuxApp: App { appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) } + private static func terminateForMissingLaunchTag() -> Never { + let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag <name> (or set CMUX_TAG for test harnesses)" + fputs("\(message)\n", stderr) + fflush(stderr) + NSLog("%@", message) + Darwin.exit(64) + } + private static func configureGhosttyEnvironment() { let fileManager = FileManager.default let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty" @@ -172,7 +212,7 @@ struct cmuxApp: App { applyAppearance() if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { DispatchQueue.main.async { - showSettingsPanel() + appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") } } } @@ -186,25 +226,25 @@ struct cmuxApp: App { .windowStyle(.hiddenTitleBar) .commands { CommandGroup(replacing: .appSettings) { - Button("Settings…") { - showSettingsPanel() + Button(String(localized: "menu.app.settings", defaultValue: "Settings…")) { + appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } .keyboardShortcut(",", modifiers: .command) } CommandGroup(replacing: .appInfo) { - Button("About cmux") { + Button(String(localized: "menu.app.about", defaultValue: "About cmux")) { showAboutPanel() } - Button("Ghostty Settings…") { + Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) { GhosttyApp.shared.openConfigurationInTextEdit() } - Button("Reload Configuration") { - GhosttyApp.shared.reloadConfiguration() + Button(String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration")) { + GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration") } .keyboardShortcut(",", modifiers: [.command, .shift]) Divider() - Button("Check for Updates…") { + Button(String(localized: "menu.app.checkForUpdates", defaultValue: "Check for Updates…")) { appDelegate.checkForUpdates(nil) } InstallUpdateMenuItem(model: appDelegate.updateViewModel) @@ -230,16 +270,7 @@ struct cmuxApp: App { } #endif - CommandMenu("Update Logs") { - Button("Copy Update Logs") { - appDelegate.copyUpdateLogs(nil) - } - Button("Copy Focus Logs") { - appDelegate.copyFocusLogs(nil) - } - } - - CommandMenu("Notifications") { + CommandMenu(String(localized: "menu.notifications.title", defaultValue: "Notifications")) { let snapshot = notificationMenuSnapshot Button(snapshot.stateHintTitle) {} @@ -257,21 +288,21 @@ struct cmuxApp: App { Divider() } - Button("Show Notifications") { + splitCommandButton(title: String(localized: "menu.notifications.show", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) { showNotificationsPopover() } - Button("Jump to Latest Unread") { + splitCommandButton(title: String(localized: "menu.notifications.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: jumpToUnreadMenuShortcut) { appDelegate.jumpToLatestUnread() } .disabled(!snapshot.hasUnreadNotifications) - Button("Mark All Read") { + Button(String(localized: "menu.notifications.markAllRead", defaultValue: "Mark All Read")) { notificationStore.markAllRead() } .disabled(!snapshot.hasUnreadNotifications) - Button("Clear All") { + Button(String(localized: "menu.notifications.clearAll", defaultValue: "Clear All")) { notificationStore.clearAll() } .disabled(!snapshot.hasNotifications) @@ -287,6 +318,19 @@ struct cmuxApp: App { appDelegate.openDebugScrollbackTab(nil) } + Button("Open Workspaces for All Workspace Colors") { + appDelegate.openDebugColorComparisonWorkspaces(nil) + } + + Button( + String( + localized: "debug.menu.openStressWorkspacesWithLoadedSurfaces", + defaultValue: "Open Stress Workspaces and Load All Terminals" + ) + ) { + appDelegate.openDebugStressWorkspacesWithLoadedSurfaces(nil) + } + Divider() Menu("Debug Windows") { Button("Debug Window Controls…") { @@ -318,6 +362,10 @@ struct cmuxApp: App { } Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints) + Toggle( + String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"), + isOn: $showSidebarDevBuildBanner + ) Divider() @@ -329,6 +377,15 @@ struct cmuxApp: App { Divider() + Button(String(localized: "menu.updateLogs.copyUpdateLogs", defaultValue: "Copy Update Logs")) { + appDelegate.copyUpdateLogs(nil) + } + Button(String(localized: "menu.updateLogs.copyFocusLogs", defaultValue: "Copy Focus Logs")) { + appDelegate.copyFocusLogs(nil) + } + + Divider() + Button("Trigger Sentry Test Crash") { appDelegate.triggerSentryTestCrash(nil) } @@ -337,166 +394,229 @@ struct cmuxApp: App { // New tab commands CommandGroup(replacing: .newItem) { - Button("New Window") { + splitCommandButton(title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), shortcut: newWindowMenuShortcut) { appDelegate.openNewMainWindow(nil) } - .keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Workspace") { - (AppDelegate.shared?.tabManager ?? tabManager).addTab() + splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "New Workspace"), shortcut: newWorkspaceMenuShortcut) { + if let appDelegate = AppDelegate.shared { + if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil { +#if DEBUG + FocusLogStore.shared.append( + "cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil" + ) +#endif + appDelegate.openNewMainWindow(nil) + } + } else { + activeTabManager.addTab() + } + } + + splitCommandButton(title: String(localized: "menu.file.openFolder", defaultValue: "Open Folder…"), shortcut: openFolderMenuShortcut) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.title = String(localized: "menu.file.openFolder.panelTitle", defaultValue: "Open Folder") + panel.prompt = String(localized: "menu.file.openFolder.panelPrompt", defaultValue: "Open") + if panel.runModal() == .OK, let url = panel.url { + if let appDelegate = AppDelegate.shared { + if appDelegate.addWorkspaceInPreferredMainWindow( + workingDirectory: url.path, + debugSource: "menu.openFolder" + ) == nil { + appDelegate.openNewMainWindow(nil) + } + } else { + activeTabManager.addWorkspace(workingDirectory: url.path) + } + } } } // Close tab/workspace CommandGroup(after: .newItem) { + Button(String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…")) { + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) + } + .keyboardShortcut("p", modifiers: [.command]) + + Button(String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…")) { + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) + } + .keyboardShortcut("p", modifiers: [.command, .shift]) + + Divider() + // Terminal semantics: // Cmd+W closes the focused tab (with confirmation if needed). If this is the last // tab in the last workspace, it closes the window. - Button("Close Tab") { + Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) { closePanelOrWindow() } .keyboardShortcut("w", modifiers: .command) + Button(String(localized: "menu.file.closeOtherTabs", defaultValue: "Close Other Tabs in Pane")) { + closeOtherTabsInFocusedPane() + } + .keyboardShortcut("t", modifiers: [.command, .option]) + .disabled(!activeTabManager.canCloseOtherTabsInFocusedPane()) + // Cmd+Shift+W closes the current workspace (with confirmation if needed). If this // is the last workspace, it closes the window. - Button("Close Workspace") { + splitCommandButton(title: String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace"), shortcut: closeWorkspaceMenuShortcut) { closeTabOrWindow() } - .keyboardShortcut("w", modifiers: [.command, .shift]) - Button("Reopen Closed Browser Panel") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel() + Menu(String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace")) { + workspaceCommandMenuContent(manager: activeTabManager) + } + + Button(String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel")) { + _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() } .keyboardShortcut("t", modifiers: [.command, .shift]) } // Find CommandGroup(after: .textEditing) { - Menu("Find") { - Button("Find…") { - (AppDelegate.shared?.tabManager ?? tabManager).startSearch() + Menu(String(localized: "menu.find.title", defaultValue: "Find")) { + Button(String(localized: "menu.find.find", defaultValue: "Find…")) { +#if DEBUG + dlog("find.menu Cmd+F fired") +#endif + activeTabManager.startSearch() } .keyboardShortcut("f", modifiers: .command) - Button("Find Next") { - (AppDelegate.shared?.tabManager ?? tabManager).findNext() + Button(String(localized: "menu.find.findNext", defaultValue: "Find Next")) { + activeTabManager.findNext() } .keyboardShortcut("g", modifiers: .command) - Button("Find Previous") { - (AppDelegate.shared?.tabManager ?? tabManager).findPrevious() + Button(String(localized: "menu.find.findPrevious", defaultValue: "Find Previous")) { + activeTabManager.findPrevious() } .keyboardShortcut("g", modifiers: [.command, .shift]) Divider() - Button("Hide Find Bar") { - (AppDelegate.shared?.tabManager ?? tabManager).hideFind() + Button(String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar")) { + activeTabManager.hideFind() } .keyboardShortcut("f", modifiers: [.command, .shift]) - .disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible)) + .disabled(!(activeTabManager.isFindVisible)) Divider() - Button("Use Selection for Find") { - (AppDelegate.shared?.tabManager ?? tabManager).searchSelection() + Button(String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find")) { + activeTabManager.searchSelection() } .keyboardShortcut("e", modifiers: .command) - .disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind)) + .disabled(!(activeTabManager.canUseSelectionForFind)) } } // Tab navigation CommandGroup(after: .toolbar) { - Button("Toggle Sidebar") { - sidebarState.toggle() + splitCommandButton(title: String(localized: "menu.view.toggleSidebar", defaultValue: "Toggle Sidebar"), shortcut: toggleSidebarMenuShortcut) { + if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true { + sidebarState.toggle() + } } Divider() - Button("Next Surface") { - (AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface() + splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: nextSurfaceMenuShortcut) { + activeTabManager.selectNextSurface() } - Button("Previous Surface") { - (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface() + splitCommandButton(title: String(localized: "menu.view.previousSurface", defaultValue: "Previous Surface"), shortcut: prevSurfaceMenuShortcut) { + activeTabManager.selectPreviousSurface() } - Button("Back") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack() + Button(String(localized: "menu.view.back", defaultValue: "Back")) { + activeTabManager.focusedBrowserPanel?.goBack() } .keyboardShortcut("[", modifiers: .command) - Button("Forward") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward() + Button(String(localized: "menu.view.forward", defaultValue: "Forward")) { + activeTabManager.focusedBrowserPanel?.goForward() } .keyboardShortcut("]", modifiers: .command) - Button("Reload Page") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload() + Button(String(localized: "menu.view.reloadPage", defaultValue: "Reload Page")) { + activeTabManager.focusedBrowserPanel?.reload() } .keyboardShortcut("r", modifiers: .command) - splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + splitCommandButton(title: String(localized: "menu.view.toggleDevTools", defaultValue: "Toggle Developer Tools"), shortcut: toggleBrowserDeveloperToolsMenuShortcut) { + let manager = activeTabManager if !manager.toggleDeveloperToolsFocusedBrowser() { NSSound.beep() } } - splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + splitCommandButton(title: String(localized: "menu.view.showJSConsole", defaultValue: "Show JavaScript Console"), shortcut: showBrowserJavaScriptConsoleMenuShortcut) { + let manager = activeTabManager if !manager.showJavaScriptConsoleFocusedBrowser() { NSSound.beep() } } - Button("Zoom In") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() + Button(String(localized: "menu.view.zoomIn", defaultValue: "Zoom In")) { + _ = activeTabManager.zoomInFocusedBrowser() } .keyboardShortcut("=", modifiers: .command) - Button("Zoom Out") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser() + Button(String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out")) { + _ = activeTabManager.zoomOutFocusedBrowser() } .keyboardShortcut("-", modifiers: .command) - Button("Actual Size") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser() + Button(String(localized: "menu.view.actualSize", defaultValue: "Actual Size")) { + _ = activeTabManager.resetZoomFocusedBrowser() } .keyboardShortcut("0", modifiers: .command) - Button("Clear Browser History") { + Button(String(localized: "menu.view.clearBrowserHistory", defaultValue: "Clear Browser History")) { BrowserHistoryStore.shared.clearHistory() } - Button("Import From Browser…") { + Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) { BrowserDataImportCoordinator.shared.presentImportDialog() } - Button("Next Workspace") { - (AppDelegate.shared?.tabManager ?? tabManager).selectNextTab() + splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) { + activeTabManager.selectNextTab() } - Button("Previous Workspace") { - (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab() + splitCommandButton(title: String(localized: "menu.view.previousWorkspace", defaultValue: "Previous Workspace"), shortcut: prevWorkspaceMenuShortcut) { + activeTabManager.selectPreviousTab() + } + + splitCommandButton(title: String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…"), shortcut: renameWorkspaceMenuShortcut) { + _ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette() } Divider() - splitCommandButton(title: "Split Right", shortcut: splitRightMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitRight", defaultValue: "Split Right"), shortcut: splitRightMenuShortcut) { performSplitFromMenu(direction: .right) } - splitCommandButton(title: "Split Down", shortcut: splitDownMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitDown", defaultValue: "Split Down"), shortcut: splitDownMenuShortcut) { performSplitFromMenu(direction: .down) } - splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitBrowserRight", defaultValue: "Split Browser Right"), shortcut: splitBrowserRightMenuShortcut) { performBrowserSplitFromMenu(direction: .right) } - splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitBrowserDown", defaultValue: "Split Browser Down"), shortcut: splitBrowserDownMenuShortcut) { performBrowserSplitFromMenu(direction: .down) } @@ -504,8 +624,8 @@ struct cmuxApp: App { // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in - Button("Workspace \(number)") { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + Button(String(localized: "menu.view.workspace", defaultValue: "Workspace \(number)")) { + let manager = activeTabManager if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) { manager.selectTab(at: targetIndex) } @@ -515,11 +635,11 @@ struct cmuxApp: App { Divider() - Button("Jump to Latest Unread") { + splitCommandButton(title: String(localized: "menu.view.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: jumpToUnreadMenuShortcut) { AppDelegate.shared?.jumpToLatestUnread() } - Button("Show Notifications") { + splitCommandButton(title: String(localized: "menu.view.showNotifications", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) { showNotificationsPopover() } } @@ -531,11 +651,6 @@ struct cmuxApp: App { NSApp.activate(ignoringOtherApps: true) } - private func showSettingsPanel() { - SettingsWindowController.shared.show() - NSApp.activate(ignoringOtherApps: true) - } - private func applyAppearance() { let mode = AppearanceSettings.mode(for: appearanceMode) if appearanceMode != mode.rawValue { @@ -578,6 +693,58 @@ struct cmuxApp: App { decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut) } + private var toggleSidebarMenuShortcut: StoredShortcut { + decodeShortcut(from: toggleSidebarShortcutData, fallback: KeyboardShortcutSettings.Action.toggleSidebar.defaultShortcut) + } + + private var newWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut(from: newWorkspaceShortcutData, fallback: KeyboardShortcutSettings.Action.newTab.defaultShortcut) + } + + private var newWindowMenuShortcut: StoredShortcut { + decodeShortcut(from: newWindowShortcutData, fallback: KeyboardShortcutSettings.Action.newWindow.defaultShortcut) + } + + private var openFolderMenuShortcut: StoredShortcut { + decodeShortcut(from: openFolderShortcutData, fallback: KeyboardShortcutSettings.Action.openFolder.defaultShortcut) + } + + private var showNotificationsMenuShortcut: StoredShortcut { + decodeShortcut( + from: showNotificationsShortcutData, + fallback: KeyboardShortcutSettings.Action.showNotifications.defaultShortcut + ) + } + + private var jumpToUnreadMenuShortcut: StoredShortcut { + decodeShortcut( + from: jumpToUnreadShortcutData, + fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut + ) + } + + private var nextSurfaceMenuShortcut: StoredShortcut { + decodeShortcut(from: nextSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.nextSurface.defaultShortcut) + } + + private var prevSurfaceMenuShortcut: StoredShortcut { + decodeShortcut(from: prevSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.prevSurface.defaultShortcut) + } + + private var nextWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: nextWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + ) + } + + private var prevWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: prevWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + ) + } + private var splitDownMenuShortcut: StoredShortcut { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } @@ -610,10 +777,30 @@ struct cmuxApp: App { ) } + private var renameWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: renameWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + ) + } + + private var closeWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: closeWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } + private var activeTabManager: TabManager { + AppDelegate.shared?.synchronizeActiveMainWindowContext( + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) ?? tabManager + } + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { guard !data.isEmpty, let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { @@ -649,63 +836,224 @@ struct cmuxApp: App { _ = tabManager.createBrowserSplit(direction: direction) } + private func selectedWorkspaceIndex(in manager: TabManager, workspaceId: UUID) -> Int? { + manager.tabs.firstIndex { $0.id == workspaceId } + } + + private func selectedWorkspaceWindowMoveTargets(in manager: TabManager) -> [AppDelegate.WindowMoveTarget] { + let referenceWindowId = AppDelegate.shared?.windowId(for: manager) + return AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + } + + private func toggleSelectedWorkspacePinned(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + manager.setPinned(workspace, pinned: !workspace.isPinned) + } + + private func clearSelectedWorkspaceCustomName(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + manager.clearCustomTitle(tabId: workspace.id) + } + + private func moveSelectedWorkspace(in manager: TabManager, by delta: Int) { + guard let workspace = manager.selectedWorkspace, + let currentIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return } + let targetIndex = currentIndex + delta + guard targetIndex >= 0, targetIndex < manager.tabs.count else { return } + _ = manager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex) + manager.selectWorkspace(workspace) + } + + private func moveSelectedWorkspaceToTop(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + manager.moveTabsToTop([workspace.id]) + manager.selectWorkspace(workspace) + } + + private func moveSelectedWorkspace(in manager: TabManager, toWindow windowId: UUID) { + guard let workspace = manager.selectedWorkspace else { return } + _ = AppDelegate.shared?.moveWorkspaceToWindow(workspaceId: workspace.id, windowId: windowId, focus: true) + } + + private func moveSelectedWorkspaceToNewWindow(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + _ = AppDelegate.shared?.moveWorkspaceToNewWindow(workspaceId: workspace.id, focus: true) + } + + private func closeWorkspaceIds( + _ workspaceIds: [UUID], + in manager: TabManager, + allowPinned: Bool + ) { + for workspaceId in workspaceIds { + guard let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { continue } + guard allowPinned || !workspace.isPinned else { continue } + manager.closeWorkspaceWithConfirmation(workspace) + } + } + + private func closeOtherSelectedWorkspacePeers(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + let workspaceIds = manager.tabs.compactMap { $0.id == workspace.id ? nil : $0.id } + closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false) + } + + private func closeSelectedWorkspacesBelow(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace, + let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return } + let workspaceIds = manager.tabs.suffix(from: anchorIndex + 1).map(\.id) + closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false) + } + + private func closeSelectedWorkspacesAbove(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace, + let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return } + let workspaceIds = manager.tabs.prefix(upTo: anchorIndex).map(\.id) + closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false) + } + + private func selectedWorkspaceHasUnreadNotifications(in manager: TabManager) -> Bool { + guard let workspaceId = manager.selectedWorkspace?.id else { return false } + return notificationStore.notifications.contains { $0.tabId == workspaceId && !$0.isRead } + } + + private func selectedWorkspaceHasReadNotifications(in manager: TabManager) -> Bool { + guard let workspaceId = manager.selectedWorkspace?.id else { return false } + return notificationStore.notifications.contains { $0.tabId == workspaceId && $0.isRead } + } + + private func markSelectedWorkspaceRead(in manager: TabManager) { + guard let workspaceId = manager.selectedWorkspace?.id else { return } + notificationStore.markRead(forTabId: workspaceId) + } + + private func markSelectedWorkspaceUnread(in manager: TabManager) { + guard let workspaceId = manager.selectedWorkspace?.id else { return } + notificationStore.markUnread(forTabId: workspaceId) + } + + @ViewBuilder + private func workspaceCommandMenuContent(manager: TabManager) -> some View { + let workspace = manager.selectedWorkspace + let workspaceIndex = workspace.flatMap { selectedWorkspaceIndex(in: manager, workspaceId: $0.id) } + let windowMoveTargets = selectedWorkspaceWindowMoveTargets(in: manager) + + Button( + workspace?.isPinned == true + ? String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace") + : String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace") + ) { + toggleSelectedWorkspacePinned(in: manager) + } + .disabled(workspace == nil) + + Button(String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…")) { + _ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette() + } + .disabled(workspace == nil) + + if workspace?.hasCustomTitle == true { + Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { + clearSelectedWorkspaceCustomName(in: manager) + } + } + + Divider() + + Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { + moveSelectedWorkspace(in: manager, by: -1) + } + .disabled(workspaceIndex == nil || workspaceIndex == 0) + + Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { + moveSelectedWorkspace(in: manager, by: 1) + } + .disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1) + + Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { + moveSelectedWorkspaceToTop(in: manager) + } + .disabled(workspace == nil || workspaceIndex == 0) + + Menu(String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window")) { + Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { + moveSelectedWorkspaceToNewWindow(in: manager) + } + .disabled(workspace == nil) + + if !windowMoveTargets.isEmpty { + Divider() + } + + ForEach(windowMoveTargets) { target in + Button(target.label) { + moveSelectedWorkspace(in: manager, toWindow: target.windowId) + } + .disabled(target.isCurrentWindow || workspace == nil) + } + } + .disabled(workspace == nil) + + Divider() + + Button(String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace")) { + manager.closeCurrentWorkspaceWithConfirmation() + } + .disabled(workspace == nil) + + Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { + closeOtherSelectedWorkspacePeers(in: manager) + } + .disabled(workspace == nil || manager.tabs.count <= 1) + + Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { + closeSelectedWorkspacesBelow(in: manager) + } + .disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1) + + Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { + closeSelectedWorkspacesAbove(in: manager) + } + .disabled(workspaceIndex == nil || workspaceIndex == 0) + + Divider() + + Button(String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read")) { + markSelectedWorkspaceRead(in: manager) + } + .disabled(!selectedWorkspaceHasUnreadNotifications(in: manager)) + + Button(String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread")) { + markSelectedWorkspaceUnread(in: manager) + } + .disabled(!selectedWorkspaceHasReadNotifications(in: manager)) + } + @ViewBuilder private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View { - if let key = keyEquivalent(for: shortcut) { + if let key = shortcut.keyEquivalent { Button(title, action: action) - .keyboardShortcut(key, modifiers: eventModifiers(for: shortcut)) + .keyboardShortcut(key, modifiers: shortcut.eventModifiers) } else { Button(title, action: action) } } - private func keyEquivalent(for shortcut: StoredShortcut) -> KeyEquivalent? { - switch shortcut.key { - case "←": - return .leftArrow - case "→": - return .rightArrow - case "↑": - return .upArrow - case "↓": - return .downArrow - case "\t": - return .tab - default: - let lowered = shortcut.key.lowercased() - guard lowered.count == 1, let character = lowered.first else { return nil } - return KeyEquivalent(character) - } - } - - private func eventModifiers(for shortcut: StoredShortcut) -> EventModifiers { - var modifiers: EventModifiers = [] - if shortcut.command { - modifiers.insert(.command) - } - if shortcut.shift { - modifiers.insert(.shift) - } - if shortcut.option { - modifiers.insert(.option) - } - if shortcut.control { - modifiers.insert(.control) - } - return modifiers - } - private func closePanelOrWindow() { if let window = NSApp.keyWindow, window.identifier?.rawValue == "cmux.settings" { window.performClose(nil) return } - (AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation() + activeTabManager.closeCurrentPanelWithConfirmation() + } + + private func closeOtherTabsInFocusedPane() { + activeTabManager.closeOtherTabsInFocusedPaneWithConfirmation() } private func closeTabOrWindow() { - (AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation() + activeTabManager.closeCurrentTabWithConfirmation() } private func showNotificationsPopover() { @@ -1188,6 +1536,8 @@ private enum DebugWindowConfigSnapshot { sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18))) sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0))) sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout)) + sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue)) + sidebarDevBuildBannerVisible=\(boolValue(defaults, key: DevBuildBannerDebugSettings.sidebarBannerVisibleKey, fallback: DevBuildBannerDebugSettings.defaultShowSidebarBanner)) shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX))) shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX))) @@ -1195,10 +1545,11 @@ private enum DebugWindowConfigSnapshot { shortcutHintPaneTabXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintXKey, fallback: ShortcutHintDebugSettings.defaultPaneHintX))) shortcutHintPaneTabYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintYKey, fallback: ShortcutHintDebugSettings.defaultPaneHintY))) shortcutHintAlwaysShow=\(boolValue(defaults, key: ShortcutHintDebugSettings.alwaysShowHintsKey, fallback: ShortcutHintDebugSettings.defaultAlwaysShowHints)) + shortcutHintShowOnCommandHold=\(boolValue(defaults, key: ShortcutHintDebugSettings.showHintsOnCommandHoldKey, fallback: ShortcutHintDebugSettings.defaultShowHintsOnCommandHold)) """ let backgroundPayload = """ - bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: true)) + bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: false)) bgGlassMaterial=\(stringValue(defaults, key: "bgGlassMaterial", fallback: "hudWindow")) bgGlassTintHex=\(stringValue(defaults, key: "bgGlassTintHex", fallback: "#000000")) bgGlassTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "bgGlassTintOpacity", fallback: 0.03))) @@ -1284,6 +1635,8 @@ private struct DebugWindowControlsView: View { @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @@ -1296,6 +1649,17 @@ private struct DebugWindowControlsView: View { BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding<String> { + Binding( + get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { @@ -1361,6 +1725,22 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } + GroupBox("Active Workspace Indicator") { + VStack(alignment: .leading, spacing: 8) { + Picker("Style", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + .pickerStyle(.menu) + + Button("Reset Indicator Style") { + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + } + } + .padding(.top, 2) + } + GroupBox("Titlebar Spacing") { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { @@ -1548,7 +1928,7 @@ private final class AcknowledgmentsWindowController: NSWindowController, NSWindo defer: false ) window.isReleasedWhenClosed = false - window.title = "Third-Party Licenses" + window.title = String(localized: "about.licenses.windowTitle", defaultValue: "Third-Party Licenses") window.identifier = NSUserInterfaceItemIdentifier("cmux.licenses") window.center() window.contentView = NSHostingView(rootView: AcknowledgmentsView()) @@ -1573,7 +1953,7 @@ private struct AcknowledgmentsView: View { let text = try? String(contentsOf: url) { return text } - return "Licenses file not found." + return String(localized: "about.licenses.notFound", defaultValue: "Licenses file not found.") }() var body: some View { @@ -1587,7 +1967,7 @@ private struct AcknowledgmentsView: View { } } -private final class SettingsWindowController: NSWindowController, NSWindowDelegate { +final class SettingsWindowController: NSWindowController, NSWindowDelegate { static let shared = SettingsWindowController() private init() { @@ -1612,13 +1992,46 @@ private final class SettingsWindowController: NSWindowController, NSWindowDelega fatalError("init(coder:) has not been implemented") } - func show() { + func show(navigationTarget: SettingsNavigationTarget? = nil) { guard let window else { return } +#if DEBUG + dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") +#endif SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) if !window.isVisible { window.center() } window.makeKeyAndOrderFront(nil) + if let navigationTarget { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + SettingsNavigationRequest.post(navigationTarget) + } + } +#if DEBUG + dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") +#endif + } +} + +enum SettingsNavigationTarget: String { + case keyboardShortcuts +} + +enum SettingsNavigationRequest { + static let notificationName = Notification.Name("cmux.settings.navigate") + private static let targetKey = "target" + + static func post(_ target: SettingsNavigationTarget) { + NotificationCenter.default.post( + name: notificationName, + object: nil, + userInfo: [targetKey: target.rawValue] + ) + } + + static func target(from notification: Notification) -> SettingsNavigationTarget? { + guard let rawValue = notification.userInfo?[targetKey] as? String else { return nil } + return SettingsNavigationTarget(rawValue: rawValue) } } @@ -1683,10 +2096,10 @@ private struct AboutPanelView: View { VStack(alignment: .center, spacing: 32) { VStack(alignment: .center, spacing: 8) { - Text("cmux") + Text(String(localized: "about.appName", defaultValue: "cmux")) .bold() .font(.title) - Text("A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.") + Text(String(localized: "about.description", defaultValue: "A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.")) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .font(.caption) @@ -1697,31 +2110,31 @@ private struct AboutPanelView: View { VStack(spacing: 2) { if let version { - AboutPropertyRow(label: "Version", text: version) + AboutPropertyRow(label: String(localized: "about.version", defaultValue: "Version"), text: version) } if let build { - AboutPropertyRow(label: "Build", text: build) + AboutPropertyRow(label: String(localized: "about.build", defaultValue: "Build"), text: build) } let commitText = commit ?? "—" let commitURL = commit.flatMap { hash in URL(string: "https://github.com/manaflow-ai/cmux/commit/\(hash)") } - AboutPropertyRow(label: "Commit", text: commitText, url: commitURL) + AboutPropertyRow(label: String(localized: "about.commit", defaultValue: "Commit"), text: commitText, url: commitURL) } .frame(maxWidth: .infinity) HStack(spacing: 8) { if let url = docsURL { - Button("Docs") { + Button(String(localized: "about.docs", defaultValue: "Docs")) { openURL(url) } } if let url = githubURL { - Button("GitHub") { + Button(String(localized: "about.github", defaultValue: "GitHub")) { openURL(url) } } - Button("Licenses") { + Button(String(localized: "about.licenses", defaultValue: "Licenses")) { AcknowledgmentsWindowController.shared.show() } } @@ -1762,6 +2175,21 @@ private struct SidebarDebugView: View { @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + + private var selectedSidebarIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding<String> { + Binding( + get: { selectedSidebarIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } var body: some View { ScrollView { @@ -1863,6 +2291,17 @@ private struct SidebarDebugView: View { .padding(.top, 2) } + GroupBox("Active Workspace Indicator") { + VStack(alignment: .leading, spacing: 8) { + Picker("Style", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + } + .padding(.top, 2) + } + GroupBox("Workspace Metadata") { VStack(alignment: .leading, spacing: 8) { Toggle("Render branch list vertically", isOn: $sidebarBranchVerticalLayout) @@ -1890,6 +2329,9 @@ private struct SidebarDebugView: View { Button("Reset Hints") { resetShortcutHintOffsets() } + Button("Reset Active Indicator") { + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + } } Button("Copy Config") { @@ -1957,6 +2399,8 @@ private struct SidebarDebugView: View { sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity)) sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout) + sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle) + sidebarDevBuildBannerVisible=\(showSidebarDevBuildBanner) shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) @@ -2192,7 +2636,7 @@ private struct BackgroundDebugView: View { @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 @AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow" - @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true + @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false var body: some View { ScrollView { @@ -2238,7 +2682,7 @@ private struct BackgroundDebugView: View { bgGlassTintHex = "#000000" bgGlassTintOpacity = 0.03 bgGlassMaterial = "hudWindow" - bgGlassEnabled = true + bgGlassEnabled = false updateWindowGlassTint() } @@ -2384,13 +2828,13 @@ enum AppearanceMode: String, CaseIterable, Identifiable { var displayName: String { switch self { case .system: - return "System" + return String(localized: "appearance.system", defaultValue: "System") case .light: - return "Light" + return String(localized: "appearance.light", defaultValue: "Light") case .dark: - return "Dark" + return String(localized: "appearance.dark", defaultValue: "Dark") case .auto: - return "Auto" + return String(localized: "appearance.auto", defaultValue: "Auto") } } } @@ -2420,6 +2864,127 @@ enum AppearanceSettings { } } +enum AppLanguage: String, CaseIterable, Identifiable { + case system + case en + case ar + case bs + case zhHans = "zh-Hans" + case zhHant = "zh-Hant" + case da + case de + case es + case fr + case it + case ja + case ko + case nb + case pl + case ptBR = "pt-BR" + case ru + case th + case tr + + var id: String { rawValue } + + var displayName: String { + switch self { + case .system: return String(localized: "language.system", defaultValue: "System") + case .en: return "English" + case .ar: return "\u{200E}العربية (Arabic)" + case .bs: return "Bosanski (Bosnian)" + case .zhHans: return "简体中文 (Chinese Simplified)" + case .zhHant: return "繁體中文 (Chinese Traditional)" + case .da: return "Dansk (Danish)" + case .de: return "Deutsch (German)" + case .es: return "Español (Spanish)" + case .fr: return "Français (French)" + case .it: return "Italiano (Italian)" + case .ja: return "日本語 (Japanese)" + case .ko: return "한국어 (Korean)" + case .nb: return "Norsk (Norwegian)" + case .pl: return "Polski (Polish)" + case .ptBR: return "Português (Brasil)" + case .ru: return "Русский (Russian)" + case .th: return "ไทย (Thai)" + case .tr: return "Türkçe (Turkish)" + } + } +} + +enum LanguageSettings { + static let languageKey = "appLanguage" + static let defaultLanguage: AppLanguage = .system + + static func apply(_ language: AppLanguage) { + if language == .system { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } else { + UserDefaults.standard.set([language.rawValue], forKey: "AppleLanguages") + } + } + + static var languageAtLaunch: AppLanguage = { + let stored = UserDefaults.standard.string(forKey: languageKey) + guard let stored, let lang = AppLanguage(rawValue: stored) else { return .system } + return lang + }() +} + +enum AppIconMode: String, CaseIterable, Identifiable { + case automatic + case light + case dark + + var id: String { rawValue } + + var displayName: String { + switch self { + case .automatic: return String(localized: "appIcon.automatic", defaultValue: "Automatic") + case .light: return String(localized: "appIcon.light", defaultValue: "Light") + case .dark: return String(localized: "appIcon.dark", defaultValue: "Dark") + } + } + + var imageName: String? { + switch self { + case .automatic: return nil + case .light: return "AppIconLight" + case .dark: return "AppIconDark" + } + } +} + +enum AppIconSettings { + static let modeKey = "appIconMode" + static let defaultMode: AppIconMode = .automatic + + static func resolvedMode(defaults: UserDefaults = .standard) -> AppIconMode { + guard let raw = defaults.string(forKey: modeKey), + let mode = AppIconMode(rawValue: raw) else { + return defaultMode + } + return mode + } + + static func applyIcon(_ mode: AppIconMode) { + switch mode { + case .automatic: + // Let the asset catalog handle appearance-based icon selection (macOS 15+). + // Reset to the default bundle icon. + NSApplication.shared.applicationIconImage = nil + case .light: + if let icon = NSImage(named: "AppIconLight") { + NSApplication.shared.applicationIconImage = icon + } + case .dark: + if let icon = NSImage(named: "AppIconDark") { + NSApplication.shared.applicationIconImage = icon + } + } + } +} + enum QuitWarningSettings { static let warnBeforeQuitKey = "warnBeforeQuitShortcut" static let defaultWarnBeforeQuit = true @@ -2436,6 +3001,18 @@ enum QuitWarningSettings { } } +enum CommandPaletteRenameSelectionSettings { + static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus" + static let defaultSelectAllOnFocus = true + + static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: selectAllOnFocusKey) == nil { + return defaultSelectAllOnFocus + } + return defaults.bool(forKey: selectAllOnFocusKey) + } +} + enum ClaudeCodeIntegrationSettings { static let hooksEnabledKey = "claudeCodeHooksEnabled" static let defaultHooksEnabled = true @@ -2448,28 +3025,76 @@ enum ClaudeCodeIntegrationSettings { } } +enum WelcomeSettings { + static let shownKey = "cmuxWelcomeShown" +} + +enum TelemetrySettings { + static let sendAnonymousTelemetryKey = "sendAnonymousTelemetry" + static let defaultSendAnonymousTelemetry = true + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: sendAnonymousTelemetryKey) == nil { + return defaultSendAnonymousTelemetry + } + return defaults.bool(forKey: sendAnonymousTelemetryKey) + } + + // Freeze telemetry enablement once per launch. Settings changes apply on next restart. + static let enabledForCurrentLaunch = isEnabled() +} + struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 + private let notificationSoundControlWidth: CGFloat = 280 + @AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue + @AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled + @AppStorage(TelemetrySettings.sendAnonymousTelemetryKey) + private var sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry @AppStorage("cmuxPortBase") private var cmuxPortBase = 9100 @AppStorage("cmuxPortRange") private var cmuxPortRange = 10 @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled - @AppStorage(BrowserForcedDarkModeSettings.enabledKey) private var browserForcedDarkModeEnabled = BrowserForcedDarkModeSettings.defaultEnabled - @AppStorage(BrowserForcedDarkModeSettings.opacityKey) private var browserForcedDarkModeOpacity = BrowserForcedDarkModeSettings.defaultOpacity + @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText + @AppStorage(NotificationSoundSettings.key) private var notificationSound = NotificationSoundSettings.defaultValue + @AppStorage(NotificationSoundSettings.customFilePathKey) + private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath + @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) + private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true + @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true + @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + @AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true + @AppStorage("sidebarShowLog") private var sidebarShowLog = true + @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true + @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true + @ObservedObject private var notificationStore = TerminalNotificationStore.shared @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -2483,15 +3108,48 @@ struct SettingsView: View { @State private var socketPasswordDraft = "" @State private var socketPasswordStatusMessage: String? @State private var socketPasswordStatusIsError = false + @State private var notificationCustomSoundStatusMessage: String? + @State private var notificationCustomSoundStatusIsError = false + @State private var showNotificationCustomSoundErrorAlert = false + @State private var notificationCustomSoundErrorAlertMessage = "" + @State private var telemetryValueAtLaunch = TelemetrySettings.enabledForCurrentLaunch + @State private var showLanguageRestartAlert = false + @State private var isResettingSettings = false + @State private var workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() + @State private var workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding<String> { + Binding( + get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } + private var selectedSocketControlMode: SocketControlMode { SocketControlSettings.migrateMode(socketControlMode) } + private var selectedBrowserThemeMode: BrowserThemeMode { + BrowserThemeSettings.mode(for: browserThemeMode) + } + + private var browserThemeModeSelection: Binding<String> { + Binding( + get: { browserThemeMode }, + set: { newValue in + browserThemeMode = BrowserThemeSettings.mode(for: newValue).rawValue + } + ) + } + private var socketModeSelection: Binding<String> { Binding( get: { socketControlMode }, @@ -2518,11 +3176,11 @@ struct SettingsView: View { private var browserHistorySubtitle: String { switch browserHistoryEntryCount { case 0: - return "No saved pages yet." + return String(localized: "settings.browser.history.subtitleEmpty", defaultValue: "No saved pages yet.") case 1: - return "1 saved page appears in omnibar suggestions." + return String(localized: "settings.browser.history.subtitleOne", defaultValue: "1 saved page appears in omnibar suggestions.") default: - return "\(browserHistoryEntryCount) saved pages appear in omnibar suggestions." + return String(localized: "settings.browser.history.subtitleMany", defaultValue: "\(browserHistoryEntryCount) saved pages appear in omnibar suggestions.") } } @@ -2534,16 +3192,211 @@ struct SettingsView: View { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } + private var hasCustomNotificationSoundFilePath: Bool { + !notificationSoundCustomFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var notificationSoundCustomFileDisplayName: String { + guard hasCustomNotificationSoundFilePath else { + return String( + localized: "settings.notifications.sound.custom.file.none", + defaultValue: "No file selected" + ) + } + return URL(fileURLWithPath: notificationSoundCustomFilePath).lastPathComponent + } + + private var canPreviewNotificationSound: Bool { + switch notificationSound { + case "none": + return false + case NotificationSoundSettings.customFileValue: + return hasCustomNotificationSoundFilePath + default: + return true + } + } + + private var notificationPermissionStatusText: String { + notificationStore.authorizationState.statusLabel + } + + private var notificationPermissionStatusColor: Color { + switch notificationStore.authorizationState { + case .authorized, .provisional, .ephemeral: + return .green + case .denied: + return .red + case .unknown, .notDetermined: + return .secondary + } + } + + private var notificationPermissionSubtitle: String { + switch notificationStore.authorizationState { + case .unknown, .notDetermined: + return "Desktop notifications are not enabled yet." + case .authorized: + return "Desktop notifications are enabled." + case .denied: + return "Desktop notifications are disabled in System Settings." + case .provisional: + return "Desktop notifications are enabled with quiet delivery." + case .ephemeral: + return "Desktop notifications are temporarily enabled." + } + } + + private var notificationPermissionActionTitle: String { + switch notificationStore.authorizationState { + case .unknown, .notDetermined: + return "Enable" + case .authorized, .denied, .provisional, .ephemeral: + return "Open Settings" + } + } + private func blurOpacity(forContentOffset offset: CGFloat) -> Double { guard let baseline = topBlurBaselineOffset else { return 0 } let reveal = (baseline - offset) / 24 return Double(min(max(reveal, 0), 1)) } + private func previewNotificationSound() { + if notificationSound == NotificationSoundSettings.customFileValue { + NotificationSoundSettings.playCustomFileSound(path: notificationSoundCustomFilePath) + return + } + NotificationSoundSettings.previewSound(value: notificationSound) + } + + private func notificationCustomSoundIssueMessage(_ issue: NotificationSoundSettings.CustomSoundPreparationIssue) -> String { + switch issue { + case .emptyPath: + return String( + localized: "settings.notifications.sound.custom.status.empty", + defaultValue: "Choose a custom audio file first." + ) + case .missingFile(let path): + let fileName = URL(fileURLWithPath: path).lastPathComponent + return String( + localized: "settings.notifications.sound.custom.status.missingFilePrefix", + defaultValue: "File not found: " + ) + fileName + case .missingFileExtension(let path): + let fileName = URL(fileURLWithPath: path).lastPathComponent + return String( + localized: "settings.notifications.sound.custom.status.missingExtensionPrefix", + defaultValue: "File needs an extension: " + ) + fileName + case .stagingFailed(_, let details): + let prefix = String( + localized: "settings.notifications.sound.custom.status.prepareFailed", + defaultValue: "Could not prepare this file for notifications. Try WAV, AIFF, or CAF." + ) + return "\(prefix) (\(details))" + } + } + + private func notificationCustomSoundReadyStatusMessage(for path: String) -> String { + let sourceExtension = URL(fileURLWithPath: path).pathExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let stagedExtension = NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) + if !sourceExtension.isEmpty, stagedExtension != sourceExtension { + return String( + localized: "settings.notifications.sound.custom.status.readyConverted", + defaultValue: "Prepared for notifications (converted to CAF)." + ) + } + return String( + localized: "settings.notifications.sound.custom.status.ready", + defaultValue: "Ready for notifications." + ) + } + + private func refreshNotificationCustomSoundStatus(showAlertOnFailure: Bool = false) { + guard notificationSound == NotificationSoundSettings.customFileValue else { + notificationCustomSoundStatusMessage = nil + notificationCustomSoundStatusIsError = false + return + } + let pathSnapshot = notificationSoundCustomFilePath + DispatchQueue.global(qos: .userInitiated).async { + let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: pathSnapshot) + DispatchQueue.main.async { + guard notificationSound == NotificationSoundSettings.customFileValue else { + notificationCustomSoundStatusMessage = nil + notificationCustomSoundStatusIsError = false + return + } + guard notificationSoundCustomFilePath == pathSnapshot else { return } + switch result { + case .success: + notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: pathSnapshot) + notificationCustomSoundStatusIsError = false + case .failure(let issue): + let message = notificationCustomSoundIssueMessage(issue) + notificationCustomSoundStatusMessage = message + notificationCustomSoundStatusIsError = true + if showAlertOnFailure { + notificationCustomSoundErrorAlertMessage = message + showNotificationCustomSoundErrorAlert = true + } + } + } + } + } + + private func chooseNotificationSoundFile() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.audio] + panel.title = String( + localized: "settings.notifications.sound.custom.choose.title", + defaultValue: "Choose Notification Sound" + ) + panel.prompt = String( + localized: "settings.notifications.sound.custom.choose.prompt", + defaultValue: "Choose" + ) + guard panel.runModal() == .OK, let url = panel.url else { return } + let selectedPath = url.path + switch NotificationSoundSettings.prepareCustomFileForNotifications(path: selectedPath) { + case .success: + notificationSoundCustomFilePath = selectedPath + notificationSound = NotificationSoundSettings.customFileValue + notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: selectedPath) + notificationCustomSoundStatusIsError = false + previewNotificationSound() + case .failure(let issue): + let message = notificationCustomSoundIssueMessage(issue) + notificationCustomSoundErrorAlertMessage = message + showNotificationCustomSoundErrorAlert = true + refreshNotificationCustomSoundStatus() + } + } + + private func handleNotificationPermissionAction() { + let state = notificationStore.authorizationState.statusLabel +#if DEBUG + dlog("notification.ui enableTapped state=\(state)") +#endif + NSLog("notification.ui enableTapped state=%@", state) + switch notificationStore.authorizationState { + case .unknown, .notDetermined: + notificationStore.requestAuthorizationFromSettings() + case .authorized, .denied, .provisional, .ephemeral: + notificationStore.openNotificationSettings() + } + } + private func saveSocketPassword() { let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { - socketPasswordStatusMessage = "Enter a password first." + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.enterFirst", defaultValue: "Enter a password first.") socketPasswordStatusIsError = true return } @@ -2551,10 +3404,10 @@ struct SettingsView: View { do { try SocketControlPasswordStore.savePassword(trimmed) socketPasswordDraft = "" - socketPasswordStatusMessage = "Password saved to keychain." + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.saved", defaultValue: "Password saved.") socketPasswordStatusIsError = false } catch { - socketPasswordStatusMessage = "Failed to save password (\(error.localizedDescription))." + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.saveFailed", defaultValue: "Failed to save password (\(error.localizedDescription)).") socketPasswordStatusIsError = true } } @@ -2563,51 +3416,86 @@ struct SettingsView: View { do { try SocketControlPasswordStore.clearPassword() socketPasswordDraft = "" - socketPasswordStatusMessage = "Password cleared." + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.cleared", defaultValue: "Password cleared.") socketPasswordStatusIsError = false } catch { - socketPasswordStatusMessage = "Failed to clear password (\(error.localizedDescription))." + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.clearFailed", defaultValue: "Failed to clear password (\(error.localizedDescription)).") socketPasswordStatusIsError = true } } var body: some View { - ZStack(alignment: .top) { + ScrollViewReader { proxy in + ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { - SettingsSectionHeader(title: "App") + SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) SettingsCard { - SettingsCardRow("Theme", controlWidth: pickerColumnWidth) { - Picker("", selection: $appearanceMode) { - ForEach(AppearanceMode.visibleCases) { mode in - Text(mode.displayName).tag(mode.rawValue) - } + SettingsPickerRow(String(localized: "settings.app.theme", defaultValue: "Theme"), controlWidth: pickerColumnWidth, selection: $appearanceMode) { + ForEach(AppearanceMode.visibleCases) { mode in + Text(mode.displayName).tag(mode.rawValue) } - .labelsHidden() - .pickerStyle(.menu) } SettingsCardDivider() SettingsCardRow( - "New Workspace Placement", - subtitle: selectedWorkspacePlacement.description, + String(localized: "settings.app.language", defaultValue: "Language"), + subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue + ? String(localized: "settings.app.language.restartSubtitle", defaultValue: "Restart cmux to apply") + : nil, controlWidth: pickerColumnWidth ) { - Picker("", selection: $newWorkspacePlacement) { - ForEach(NewWorkspacePlacement.allCases) { placement in - Text(placement.displayName).tag(placement.rawValue) + Picker("", selection: $appLanguage) { + ForEach(AppLanguage.allCases) { lang in + Text(lang.displayName).tag(lang.rawValue) } } .labelsHidden() .pickerStyle(.menu) + .onChange(of: appLanguage) { newValue in + guard !isResettingSettings else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [self] in + // Re-check current value to handle rapid changes + let current = appLanguage + if let lang = AppLanguage(rawValue: current) { + LanguageSettings.apply(lang) + } + if current != LanguageSettings.languageAtLaunch.rawValue { + showLanguageRestartAlert = true + } + } + } + } + + SettingsCardDivider() + + AppIconPickerRow( + selectedMode: appIconMode, + onSelect: { mode in + appIconMode = mode.rawValue + AppIconSettings.applyIcon(mode) + } + ) + + SettingsCardDivider() + + SettingsPickerRow( + String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), + subtitle: selectedWorkspacePlacement.description, + controlWidth: pickerColumnWidth, + selection: $newWorkspacePlacement + ) { + ForEach(NewWorkspacePlacement.allCases) { placement in + Text(placement.displayName).tag(placement.rawValue) + } } SettingsCardDivider() SettingsCardRow( - "Reorder on Notification", - subtitle: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions." + String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), + subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") ) { Toggle("", isOn: $workspaceAutoReorder) .labelsHidden() @@ -2617,8 +3505,8 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Dock Badge", - subtitle: "Show unread count on app icon (Dock and Cmd+Tab)." + String(localized: "settings.app.dockBadge", defaultValue: "Dock Badge"), + subtitle: String(localized: "settings.app.dockBadge.subtitle", defaultValue: "Show unread count on app icon (Dock and Cmd+Tab).") ) { Toggle("", isOn: $notificationDockBadgeEnabled) .labelsHidden() @@ -2628,10 +3516,126 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Warn Before Quit", + "Desktop Notifications", + subtitle: notificationPermissionSubtitle + ) { + HStack(spacing: 6) { + Text(notificationPermissionStatusText) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(notificationPermissionStatusColor) + .frame(width: 98, alignment: .trailing) + + Button(notificationPermissionActionTitle) { + handleNotificationPermissionAction() + } + .controlSize(.small) + + Button("Send Test") { + notificationStore.sendSettingsTestNotification() + } + .controlSize(.small) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"), + subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."), + controlWidth: notificationSoundControlWidth + ) { + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { + Picker("", selection: $notificationSound) { + ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in + Text(sound.label).tag(sound.value) + } + } + .labelsHidden() + Button { + previewNotificationSound() + } label: { + Image(systemName: "play.fill") + .font(.system(size: 9)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!canPreviewNotificationSound) + } + + if notificationSound == NotificationSoundSettings.customFileValue { + HStack(spacing: 6) { + Text(notificationSoundCustomFileDisplayName) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 170, alignment: .trailing) + Button( + String( + localized: "settings.notifications.sound.custom.choose.button", + defaultValue: "Choose..." + ) + ) { + chooseNotificationSoundFile() + } + .controlSize(.small) + Button( + String( + localized: "settings.notifications.sound.custom.clear.button", + defaultValue: "Clear" + ) + ) { + notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath + refreshNotificationCustomSoundStatus() + } + .controlSize(.small) + .disabled(!hasCustomNotificationSoundFilePath) + } + if let notificationCustomSoundStatusMessage { + Text(notificationCustomSoundStatusMessage) + .font(.system(size: 11)) + .foregroundStyle(notificationCustomSoundStatusIsError ? Color.red : Color.secondary) + .lineLimit(2) + .multilineTextAlignment(.trailing) + .frame(width: 260, alignment: .trailing) + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + SettingsCardDivider() + + SettingsCardRow( + "Notification Command", + subtitle: "Run a shell command when a notification arrives. $CMUX_NOTIFICATION_TITLE, $CMUX_NOTIFICATION_SUBTITLE, $CMUX_NOTIFICATION_BODY are set." + ) { + TextField("say \"done\"", text: $notificationCustomCommand) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.telemetry", defaultValue: "Send anonymous telemetry"), + subtitle: sendAnonymousTelemetry != telemetryValueAtLaunch + ? String(localized: "settings.app.telemetry.subtitleChanged", defaultValue: "Change takes effect on next launch.") + : String(localized: "settings.app.telemetry.subtitle", defaultValue: "Share anonymized crash and usage data to help improve cmux.") + ) { + Toggle("", isOn: $sendAnonymousTelemetry) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"), subtitle: warnBeforeQuitShortcut - ? "Show a confirmation before quitting with Cmd+Q." - : "Cmd+Q quits immediately without confirmation." + ? String(localized: "settings.app.warnBeforeQuit.subtitleOn", defaultValue: "Show a confirmation before quitting with Cmd+Q.") + : String(localized: "settings.app.warnBeforeQuit.subtitleOff", defaultValue: "Cmd+Q quits immediately without confirmation.") ) { Toggle("", isOn: $warnBeforeQuitShortcut) .labelsHidden() @@ -2641,60 +3645,235 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Sidebar Branch Layout", - subtitle: sidebarBranchVerticalLayout - ? "Vertical: each branch appears on its own line." - : "Inline: all branches share one line." + String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), + subtitle: commandPaletteRenameSelectAllOnFocus + ? String(localized: "settings.app.renameSelectsName.subtitleOn", defaultValue: "Command Palette rename starts with all text selected.") + : String(localized: "settings.app.renameSelectsName.subtitleOff", defaultValue: "Command Palette rename keeps the caret at the end.") ) { - Picker("", selection: $sidebarBranchVerticalLayout) { - Text("Vertical").tag(true) - Text("Inline").tag(false) - } - .labelsHidden() - .pickerStyle(.menu) - } - } - - SettingsSectionHeader(title: "Automation") - SettingsCard { - SettingsCardRow( - "Socket Control Mode", - subtitle: selectedSocketControlMode.description, - controlWidth: pickerColumnWidth - ) { - Picker("", selection: socketModeSelection) { - ForEach(SocketControlMode.uiCases) { mode in - Text(mode.displayName).tag(mode.rawValue) - } - } - .labelsHidden() - .pickerStyle(.menu) - .accessibilityIdentifier("AutomationSocketModePicker") + Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) + .labelsHidden() + .controlSize(.small) } SettingsCardDivider() - SettingsCardNote("Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.") + SettingsPickerRow( + String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"), + subtitle: sidebarBranchVerticalLayout + ? String(localized: "settings.app.sidebarBranchLayout.subtitleVertical", defaultValue: "Vertical: each branch appears on its own line.") + : String(localized: "settings.app.sidebarBranchLayout.subtitleInline", defaultValue: "Inline: all branches share one line."), + controlWidth: pickerColumnWidth, + selection: $sidebarBranchVerticalLayout + ) { + Text(String(localized: "settings.app.sidebarBranchLayout.vertical", defaultValue: "Vertical")).tag(true) + Text(String(localized: "settings.app.sidebarBranchLayout.inline", defaultValue: "Inline")).tag(false) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.showBranchDirectory", defaultValue: "Show Branch + Directory in Sidebar"), + subtitle: String(localized: "settings.app.showBranchDirectory.subtitle", defaultValue: "Display the built-in git branch and working-directory row.") + ) { + Toggle("", isOn: $sidebarShowBranchDirectory) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.showPullRequests", defaultValue: "Show Pull Requests in Sidebar"), + subtitle: String(localized: "settings.app.showPullRequests.subtitle", defaultValue: "Display review items (PR/MR/etc.) with status, number, and clickable link.") + ) { + Toggle("", isOn: $sidebarShowPullRequest) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.openSidebarPRLinks", defaultValue: "Open Sidebar PR Links in cmux Browser"), + subtitle: openSidebarPullRequestLinksInCmuxBrowser + ? String(localized: "settings.app.openSidebarPRLinks.subtitleOn", defaultValue: "Clicks open inside cmux browser.") + : String(localized: "settings.app.openSidebarPRLinks.subtitleOff", defaultValue: "Clicks open in your default browser.") + ) { + Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), + subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.") + ) { + Toggle("", isOn: $sidebarShowPorts) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.showLog", defaultValue: "Show Latest Log in Sidebar"), + subtitle: String(localized: "settings.app.showLog.subtitle", defaultValue: "Display the latest imperative log/status message.") + ) { + Toggle("", isOn: $sidebarShowLog) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.showProgress", defaultValue: "Show Progress in Sidebar"), + subtitle: String(localized: "settings.app.showProgress.subtitle", defaultValue: "Display the built-in progress bar from set_progress.") + ) { + Toggle("", isOn: $sidebarShowProgress) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"), + subtitle: String(localized: "settings.app.showMetadata.subtitle", defaultValue: "Display custom metadata from report_meta/set_status and report_meta_block.") + ) { + Toggle("", isOn: $sidebarShowMetadata) + .labelsHidden() + .controlSize(.small) + } + } + + SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) + SettingsCard { + SettingsPickerRow( + String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"), + controlWidth: pickerColumnWidth, + selection: sidebarIndicatorStyleSelection + ) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.workspaceColors.paletteNote", defaultValue: "Customize the workspace color palette used by Sidebar > Workspace Color. \"Choose Custom Color...\" entries are persisted below.")) + + ForEach(Array(workspaceTabDefaultEntries.enumerated()), id: \.element.name) { index, entry in + if index > 0 { + SettingsCardDivider() + } + SettingsCardRow( + entry.name, + subtitle: String(localized: "settings.workspaceColors.base", defaultValue: "Base: \(baseTabColorHex(for: entry.name))") + ) { + HStack(spacing: 8) { + ColorPicker( + "", + selection: defaultTabColorBinding(for: entry.name), + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) + + Text(entry.hex) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + } + + SettingsCardDivider() + + if workspaceTabCustomColors.isEmpty { + SettingsCardNote(String(localized: "settings.workspaceColors.noCustomColors", defaultValue: "Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu.")) + } else { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.workspaceColors.customColors", defaultValue: "Custom Colors")) + .font(.system(size: 13, weight: .semibold)) + + ForEach(workspaceTabCustomColors, id: \.self) { hex in + HStack(spacing: 8) { + Circle() + .fill(Color(nsColor: NSColor(hex: hex) ?? .gray)) + .frame(width: 11, height: 11) + + Text(hex) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + + Spacer(minLength: 8) + + Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { + removeWorkspaceCustomColor(hex) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), + subtitle: String(localized: "settings.workspaceColors.resetPalette.subtitle", defaultValue: "Restore built-in defaults and clear all custom colors.") + ) { + Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { + resetWorkspaceTabColors() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation")) + SettingsCard { + SettingsPickerRow( + String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), + subtitle: selectedSocketControlMode.description, + controlWidth: pickerColumnWidth, + selection: socketModeSelection, + accessibilityId: "AutomationSocketModePicker" + ) { + ForEach(SocketControlMode.uiCases) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.automation.socketMode.note", defaultValue: "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.")) if selectedSocketControlMode == .password { SettingsCardDivider() SettingsCardRow( - "Socket Password", + String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), subtitle: hasSocketPasswordConfigured - ? "Stored in login keychain." - : "No password set. External clients will be blocked until one is configured." + ? String(localized: "settings.automation.socketPassword.subtitleSet", defaultValue: "Stored in Application Support.") + : String(localized: "settings.automation.socketPassword.subtitleUnset", defaultValue: "No password set. External clients will be blocked until one is configured.") ) { HStack(spacing: 8) { - SecureField("Password", text: $socketPasswordDraft) + SecureField(String(localized: "settings.automation.socketPassword.placeholder", defaultValue: "Password"), text: $socketPasswordDraft) .textFieldStyle(.roundedBorder) .frame(width: 170) - Button(hasSocketPasswordConfigured ? "Change" : "Set") { + Button(hasSocketPasswordConfigured ? String(localized: "settings.automation.socketPassword.change", defaultValue: "Change") : String(localized: "settings.automation.socketPassword.set", defaultValue: "Set")) { saveSocketPassword() } .buttonStyle(.bordered) .controlSize(.small) .disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) if hasSocketPasswordConfigured { - Button("Clear") { + Button(String(localized: "settings.automation.socketPassword.clear", defaultValue: "Clear")) { clearSocketPassword() } .buttonStyle(.bordered) @@ -2712,21 +3891,21 @@ struct SettingsView: View { } if selectedSocketControlMode == .allowAll { SettingsCardDivider() - Text("Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.") + Text(String(localized: "settings.automation.openAccessWarning", defaultValue: "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.")) .font(.caption) .foregroundStyle(.red) .padding(.horizontal, 14) .padding(.vertical, 8) } - SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).") + SettingsCardNote(String(localized: "settings.automation.socketOverrides.note", defaultValue: "Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).")) } SettingsCard { SettingsCardRow( - "Claude Code Integration", + String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"), subtitle: claudeCodeHooksEnabled - ? "Sidebar shows Claude session status and notifications." - : "Claude Code runs without cmux integration." + ? String(localized: "settings.automation.claudeCode.subtitleOn", defaultValue: "Sidebar shows Claude session status and notifications.") + : String(localized: "settings.automation.claudeCode.subtitleOff", defaultValue: "Claude Code runs without cmux integration.") ) { Toggle("", isOn: $claudeCodeHooksEnabled) .labelsHidden() @@ -2736,11 +3915,11 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardNote("When enabled, cmux wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.") + SettingsCardNote(String(localized: "settings.automation.claudeCode.note", defaultValue: "When enabled, cmux wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.")) } SettingsCard { - SettingsCardRow("Port Base", subtitle: "Starting port for CMUX_PORT env var.", controlWidth: pickerColumnWidth) { + SettingsCardRow(String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), controlWidth: pickerColumnWidth) { TextField("", value: $cmuxPortBase, format: .number) .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) @@ -2748,7 +3927,7 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow("Port Range Size", subtitle: "Number of ports per workspace.", controlWidth: pickerColumnWidth) { + SettingsCardRow(String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), subtitle: String(localized: "settings.automation.portRange.subtitle", defaultValue: "Number of ports per workspace."), controlWidth: pickerColumnWidth) { TextField("", value: $cmuxPortRange, format: .number) .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) @@ -2756,28 +3935,25 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardNote("Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.") + SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) } - SettingsSectionHeader(title: "Browser") + SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) SettingsCard { - SettingsCardRow( - "Default Search Engine", - subtitle: "Used by the browser address bar when input is not a URL.", - controlWidth: pickerColumnWidth + SettingsPickerRow( + String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), + subtitle: String(localized: "settings.browser.searchEngine.subtitle", defaultValue: "Used by the browser address bar when input is not a URL."), + controlWidth: pickerColumnWidth, + selection: $browserSearchEngine ) { - Picker("", selection: $browserSearchEngine) { - ForEach(BrowserSearchEngine.allCases) { engine in - Text(engine.displayName).tag(engine.rawValue) - } + ForEach(BrowserSearchEngine.allCases) { engine in + Text(engine.displayName).tag(engine.rawValue) } - .labelsHidden() - .pickerStyle(.menu) } SettingsCardDivider() - SettingsCardRow("Show Search Suggestions") { + SettingsCardRow(String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions")) { Toggle("", isOn: $browserSearchSuggestionsEnabled) .labelsHidden() .controlSize(.small) @@ -2785,62 +3961,48 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow( - "Force Dark Mode", - subtitle: "Dims bright pages in the embedded browser with a lightweight overlay." + SettingsPickerRow( + String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), + subtitle: selectedBrowserThemeMode == .system + ? String(localized: "settings.browser.theme.subtitleSystem", defaultValue: "System follows app and macOS appearance.") + : String(localized: "settings.browser.theme.subtitleForced", defaultValue: "\(selectedBrowserThemeMode.displayName) forces that color scheme for compatible pages."), + controlWidth: pickerColumnWidth, + selection: browserThemeModeSelection ) { - Toggle("", isOn: $browserForcedDarkModeEnabled) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - - SettingsCardRow( - "Dimmer Opacity", - subtitle: "\(Int(BrowserForcedDarkModeSettings.normalizedOpacity(browserForcedDarkModeOpacity).rounded()))%" - ) { - HStack(spacing: 8) { - Slider( - value: Binding( - get: { - BrowserForcedDarkModeSettings.normalizedOpacity(browserForcedDarkModeOpacity) - }, - set: { newValue in - browserForcedDarkModeOpacity = BrowserForcedDarkModeSettings.normalizedOpacity(newValue) - } - ), - in: BrowserForcedDarkModeSettings.minOpacity...BrowserForcedDarkModeSettings.maxOpacity, - step: 1 - ) - .frame(width: 132) - .disabled(!browserForcedDarkModeEnabled) - - Text("\(Int(BrowserForcedDarkModeSettings.normalizedOpacity(browserForcedDarkModeOpacity).rounded()))%") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 38, alignment: .trailing) + ForEach(BrowserThemeMode.allCases) { mode in + Text(mode.displayName).tag(mode.rawValue) } } SettingsCardDivider() SettingsCardRow( - "Open Terminal Links in cmux Browser", - subtitle: "When off, links clicked in terminal output open in your default browser." + String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in cmux Browser"), + subtitle: String(localized: "settings.browser.openTerminalLinks.subtitle", defaultValue: "When off, links clicked in terminal output open in your default browser.") ) { Toggle("", isOn: $openTerminalLinksInCmuxBrowser) .labelsHidden() .controlSize(.small) } - if openTerminalLinksInCmuxBrowser { + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), + subtitle: String(localized: "settings.browser.interceptOpen.subtitle", defaultValue: "When off, `open https://...` and `open http://...` always use your default browser.") + ) { + Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + + if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser { SettingsCardDivider() VStack(alignment: .leading, spacing: 6) { SettingsCardRow( - "Hosts to Open in Embedded Browser", - subtitle: "When you click links in terminal output, only these hosts open in cmux. Other hosts open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all links in cmux." + String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), + subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") ) { EmptyView() } @@ -2859,15 +4021,40 @@ struct SettingsView: View { .padding(.horizontal, 16) .padding(.bottom, 12) } + + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), + subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage)).") + ) { + EmptyView() + } + + TextEditor(text: $browserExternalOpenPatterns) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } } SettingsCardDivider() VStack(alignment: .leading, spacing: 8) { - Text("HTTP Hosts Allowed in Embedded Browser") + Text(String(localized: "settings.browser.httpAllowlist", defaultValue: "HTTP Hosts Allowed in Embedded Browser")) .font(.system(size: 13, weight: .semibold)) - Text("Controls which HTTP (non-HTTPS) hosts can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.") + Text(String(localized: "settings.browser.httpAllowlist.description", defaultValue: "Controls which HTTP (non-HTTPS) hosts can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.")) .font(.caption) .foregroundStyle(.secondary) @@ -2887,14 +4074,14 @@ struct SettingsView: View { ViewThatFits(in: .horizontal) { HStack(alignment: .center, spacing: 10) { - Text("One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).") + Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) - Button("Save") { + Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { saveBrowserInsecureHTTPAllowlist() } .buttonStyle(.bordered) @@ -2904,13 +4091,13 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 8) { - Text("One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).") + Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) .font(.caption) .foregroundStyle(.secondary) HStack { Spacer(minLength: 0) - Button("Save") { + Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { saveBrowserInsecureHTTPAllowlist() } .buttonStyle(.bordered) @@ -2926,16 +4113,16 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow("Import From Browser", subtitle: browserImportSubtitle) { + SettingsCardRow(String(localized: "settings.browser.import", defaultValue: "Import From Browser"), subtitle: browserImportSubtitle) { HStack(spacing: 8) { - Button("Choose…") { + Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { BrowserDataImportCoordinator.shared.presentImportDialog() refreshDetectedImportBrowsers() } .buttonStyle(.bordered) .controlSize(.small) - Button("Refresh") { + Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { refreshDetectedImportBrowsers() } .buttonStyle(.bordered) @@ -2945,8 +4132,8 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) { - Button("Clear History…") { + SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { + Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { showClearBrowserHistoryConfirmation = true } .buttonStyle(.bordered) @@ -2955,8 +4142,23 @@ struct SettingsView: View { } } - SettingsSectionHeader(title: "Keyboard Shortcuts") + SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts")) + .id(SettingsNavigationTarget.keyboardShortcuts) + .accessibilityIdentifier("SettingsKeyboardShortcutsSection") SettingsCard { + SettingsCardRow( + String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), + subtitle: showShortcutHintsOnCommandHold + ? String(localized: "settings.shortcuts.showHints.subtitleOn", defaultValue: "Holding Cmd (sidebar/titlebar) or Ctrl/Cmd (pane tabs) shows shortcut hint pills.") + : String(localized: "settings.shortcuts.showHints.subtitleOff", defaultValue: "Holding Cmd or Ctrl keeps shortcut hint pills hidden.") + ) { + Toggle("", isOn: $showShortcutHintsOnCommandHold) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + let actions = KeyboardShortcutSettings.Action.allCases ForEach(Array(actions.enumerated()), id: \.element.id) { index, action in ShortcutSettingRow(action: action) @@ -2969,16 +4171,17 @@ struct SettingsView: View { } .id(shortcutResetToken) - Text("Click a shortcut value to record a new shortcut.") + Text(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record a new shortcut.")) .font(.caption) .foregroundColor(.secondary) .padding(.leading, 2) + .accessibilityIdentifier("ShortcutRecordingHint") - SettingsSectionHeader(title: "Reset") + SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset")) SettingsCard { HStack { Spacer(minLength: 0) - Button("Reset All Settings") { + Button(String(localized: "settings.reset.resetAll", defaultValue: "Reset All Settings")) { resetAllSettings() } .buttonStyle(.bordered) @@ -3044,7 +4247,7 @@ struct SettingsView: View { .opacity(0.14 + (topBlurOpacity * 0.86)) HStack { - Text("Settings") + Text(String(localized: "settings.title", defaultValue: "Settings")) .font(.system(size: 16, weight: .semibold)) .foregroundColor(.primary.opacity(0.92)) Spacer(minLength: 0) @@ -3067,10 +4270,19 @@ struct SettingsView: View { .toggleStyle(.switch) .onAppear { BrowserHistoryStore.shared.loadIfNeeded() - browserForcedDarkModeOpacity = BrowserForcedDarkModeSettings.normalizedOpacity(browserForcedDarkModeOpacity) + notificationStore.refreshAuthorizationStatus() + browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist refreshDetectedImportBrowsers() + reloadWorkspaceTabColorSettings() + refreshNotificationCustomSoundStatus() + } + .onChange(of: notificationSound) { _, _ in + refreshNotificationCustomSoundStatus() + } + .onChange(of: notificationSoundCustomFilePath) { _, _ in + refreshNotificationCustomSoundStatus() } .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in // Keep draft in sync with external changes unless the user has local unsaved edits. @@ -3081,52 +4293,128 @@ struct SettingsView: View { .onReceive(BrowserHistoryStore.shared.$entries) { entries in browserHistoryEntryCount = entries.count } + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + reloadWorkspaceTabColorSettings() + } + .onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in + guard let target = SettingsNavigationRequest.target(from: notification) else { return } + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(target, anchor: .top) + } + } + } .confirmationDialog( - "Clear browser history?", + String(localized: "settings.browser.history.clearDialog.title", defaultValue: "Clear browser history?"), isPresented: $showClearBrowserHistoryConfirmation, titleVisibility: .visible ) { - Button("Clear History", role: .destructive) { + Button(String(localized: "settings.browser.history.clearDialog.confirm", defaultValue: "Clear History"), role: .destructive) { BrowserHistoryStore.shared.clearHistory() } - Button("Cancel", role: .cancel) {} + Button(String(localized: "settings.browser.history.clearDialog.cancel", defaultValue: "Cancel"), role: .cancel) {} } message: { - Text("This removes visited-page suggestions from the browser omnibar.") + Text(String(localized: "settings.browser.history.clearDialog.message", defaultValue: "This removes visited-page suggestions from the browser omnibar.")) } .confirmationDialog( - "Enable full open access?", + String(localized: "settings.automation.openAccess.dialog.title", defaultValue: "Enable full open access?"), isPresented: $showOpenAccessConfirmation, titleVisibility: .visible ) { - Button("Enable Full Open Access", role: .destructive) { + Button(String(localized: "settings.automation.openAccess.dialog.confirm", defaultValue: "Enable Full Open Access"), role: .destructive) { socketControlMode = (pendingOpenAccessMode ?? .allowAll).rawValue pendingOpenAccessMode = nil } - Button("Cancel", role: .cancel) { + Button(String(localized: "settings.automation.openAccess.dialog.cancel", defaultValue: "Cancel"), role: .cancel) { pendingOpenAccessMode = nil } } message: { - Text("This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk.") + Text(String(localized: "settings.automation.openAccess.dialog.message", defaultValue: "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk.")) + } + .confirmationDialog( + String(localized: "settings.app.language.restartDialog.title", defaultValue: "Restart to apply language change?"), + isPresented: $showLanguageRestartAlert, + titleVisibility: .visible + ) { + Button(String(localized: "settings.app.language.restartDialog.confirm", defaultValue: "Restart Now")) { + relaunchApp() + } + Button(String(localized: "settings.app.language.restartDialog.later", defaultValue: "Later"), role: .cancel) {} + } + .alert( + String( + localized: "settings.notifications.sound.custom.error.title", + defaultValue: "Custom Notification Sound Error" + ), + isPresented: $showNotificationCustomSoundErrorAlert + ) { + Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {} + } message: { + Text(notificationCustomSoundErrorAlertMessage) + } } } + private func relaunchApp() { + let bundlePath = Bundle.main.bundlePath + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/sh") + task.arguments = ["-c", "sleep 1 && open -n -- \"$RELAUNCH_PATH\""] + task.environment = ["RELAUNCH_PATH": bundlePath] + do { + try task.run() + } catch { + return + } + NSApplication.shared.terminate(nil) + } + private func resetAllSettings() { + isResettingSettings = true + appLanguage = LanguageSettings.defaultLanguage.rawValue + LanguageSettings.apply(.system) + if appLanguage != LanguageSettings.languageAtLaunch.rawValue { + showLanguageRestartAlert = true + } appearanceMode = AppearanceSettings.defaultMode.rawValue + appIconMode = AppIconSettings.defaultMode.rawValue + AppIconSettings.applyIcon(.automatic) socketControlMode = SocketControlSettings.defaultMode.rawValue claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled + sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled - browserForcedDarkModeEnabled = BrowserForcedDarkModeSettings.defaultEnabled - browserForcedDarkModeOpacity = BrowserForcedDarkModeSettings.defaultOpacity + browserThemeMode = BrowserThemeSettings.defaultMode.rawValue openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText + notificationSound = NotificationSoundSettings.defaultValue + notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath + notificationCustomSoundStatusMessage = nil + notificationCustomSoundStatusIsError = false + showNotificationCustomSoundErrorAlert = false + notificationCustomSoundErrorAlertMessage = "" + notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + ShortcutHintDebugSettings.resetVisibilityDefaults() + alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + sidebarShowBranchDirectory = true + sidebarShowPullRequest = true + openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + sidebarShowPorts = true + sidebarShowLog = true + sidebarShowProgress = true + sidebarShowMetadata = true showOpenAccessConfirmation = false pendingOpenAccessMode = nil socketPasswordDraft = "" @@ -3134,7 +4422,45 @@ struct SettingsView: View { socketPasswordStatusIsError = false refreshDetectedImportBrowsers() KeyboardShortcutSettings.resetAll() + WorkspaceTabColorSettings.reset() + reloadWorkspaceTabColorSettings() shortcutResetToken = UUID() + DispatchQueue.main.async { isResettingSettings = false } + } + + private func defaultTabColorBinding(for name: String) -> Binding<Color> { + Binding( + get: { + let hex = WorkspaceTabColorSettings.defaultColorHex(named: name) + return Color(nsColor: NSColor(hex: hex) ?? .systemBlue) + }, + set: { newValue in + let hex = NSColor(newValue).hexString() + WorkspaceTabColorSettings.setDefaultColor(named: name, hex: hex) + reloadWorkspaceTabColorSettings() + } + ) + } + + private func baseTabColorHex(for name: String) -> String { + WorkspaceTabColorSettings.defaultPalette + .first(where: { $0.name == name })? + .hex ?? "#1565C0" + } + + private func removeWorkspaceCustomColor(_ hex: String) { + WorkspaceTabColorSettings.removeCustomColor(hex) + reloadWorkspaceTabColorSettings() + } + + private func resetWorkspaceTabColors() { + WorkspaceTabColorSettings.reset() + reloadWorkspaceTabColorSettings() + } + + private func reloadWorkspaceTabColorSettings() { + workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() + workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() } private func saveBrowserInsecureHTTPAllowlist() { @@ -3259,6 +4585,74 @@ private struct SettingsCardRow<Trailing: View>: View { } } +private struct SettingsPickerRow<SelectionValue: Hashable, PickerContent: View, ExtraTrailing: View>: View { + let title: String + let subtitle: String? + let controlWidth: CGFloat + @Binding var selection: SelectionValue + let pickerContent: PickerContent + let extraTrailing: ExtraTrailing + let accessibilityId: String? + + init( + _ title: String, + subtitle: String? = nil, + controlWidth: CGFloat, + selection: Binding<SelectionValue>, + accessibilityId: String? = nil, + @ViewBuilder content: () -> PickerContent, + @ViewBuilder extraTrailing: () -> ExtraTrailing + ) { + self.title = title + self.subtitle = subtitle + self.controlWidth = controlWidth + self._selection = selection + self.pickerContent = content() + self.extraTrailing = extraTrailing() + self.accessibilityId = accessibilityId + } + + var body: some View { + SettingsCardRow(title, subtitle: subtitle, controlWidth: controlWidth) { + HStack(spacing: 6) { + Picker("", selection: $selection) { + pickerContent + } + .labelsHidden() + .pickerStyle(.menu) + .applyIf(accessibilityId != nil) { $0.accessibilityIdentifier(accessibilityId!) } + extraTrailing + } + } + } +} + +extension SettingsPickerRow where ExtraTrailing == EmptyView { + init( + _ title: String, + subtitle: String? = nil, + controlWidth: CGFloat, + selection: Binding<SelectionValue>, + accessibilityId: String? = nil, + @ViewBuilder content: () -> PickerContent + ) { + self.init(title, subtitle: subtitle, controlWidth: controlWidth, selection: selection, accessibilityId: accessibilityId, content: content) { + EmptyView() + } + } +} + +private extension View { + @ViewBuilder + func applyIf(_ condition: Bool, transform: (Self) -> some View) -> some View { + if condition { + transform(self) + } else { + self + } + } +} + private struct SettingsCardDivider: View { var body: some View { Rectangle() @@ -3284,6 +4678,79 @@ private struct SettingsCardNote: View { } } +private struct AppIconPickerRow: View { + let selectedMode: String + let onSelect: (AppIconMode) -> Void + + private let iconSize: CGFloat = 48 + private let autoIconSize: CGFloat = 36 + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon")) + .font(.system(size: 13, weight: .medium)) + + HStack(spacing: 12) { + ForEach(AppIconMode.allCases) { mode in + let isSelected = selectedMode == mode.rawValue + Button { + onSelect(mode) + } label: { + VStack(spacing: 6) { + Group { + if mode == .automatic { + // Show both icons overlapping + ZStack { + Image("AppIconLight") + .resizable() + .interpolation(.high) + .frame(width: autoIconSize, height: autoIconSize) + .clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous)) + .offset(x: -10) + Image("AppIconDark") + .resizable() + .interpolation(.high) + .frame(width: autoIconSize, height: autoIconSize) + .clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous)) + .offset(x: 10) + } + .frame(width: iconSize, height: iconSize) + } else { + Image(mode.imageName ?? "AppIconLight") + .resizable() + .interpolation(.high) + .frame(width: iconSize, height: iconSize) + .clipShape(RoundedRectangle(cornerRadius: iconSize * 0.22, style: .continuous)) + } + } + + Text(mode.displayName) + .font(.system(size: 11)) + .foregroundColor(isSelected ? .primary : .secondary) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isSelected + ? Color.accentColor.opacity(0.12) + : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + private struct ShortcutSettingRow: View { let action: KeyboardShortcutSettings.Action @State private var shortcut: StoredShortcut diff --git a/cmux.entitlements b/cmux.entitlements index 754a6144..09e191a5 100644 --- a/cmux.entitlements +++ b/cmux.entitlements @@ -8,6 +8,10 @@ <true/> <key>com.apple.security.cs.allow-jit</key> <true/> + <key>com.apple.security.device.camera</key> + <true/> + <key>com.apple.security.device.audio-input</key> + <true/> <key>com.apple.security.automation.apple-events</key> <true/> </dict> diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift new file mode 100644 index 00000000..d084e71d --- /dev/null +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -0,0 +1,2352 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class AppDelegateShortcutRoutingTests: XCTestCase { + private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + private var actionsWithPersistedShortcut: Set<KeyboardShortcutSettings.Action> = [] + + override func setUp() { + super.setUp() + actionsWithPersistedShortcut = Set( + KeyboardShortcutSettings.Action.allCases.filter { + UserDefaults.standard.object(forKey: $0.defaultsKey) != nil + } + ) + savedShortcutsByAction = Dictionary( + uniqueKeysWithValues: actionsWithPersistedShortcut.map { action in + (action, KeyboardShortcutSettings.shortcut(for: action)) + } + ) + KeyboardShortcutSettings.resetAll() + } + + override func tearDown() { + AppDelegate.shared?.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + AppDelegate.shared?.debugCloseMainWindowConfirmationHandler = nil + AppDelegate.shared?.dismissNotificationsPopoverIfShown() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + for action in KeyboardShortcutSettings.Action.allCases { + if actionsWithPersistedShortcut.contains(action), + let savedShortcut = savedShortcutsByAction[action] { + KeyboardShortcutSettings.setShortcut(savedShortcut, for: action) + } else { + KeyboardShortcutSettings.resetShortcut(for: action) + } + } + super.tearDown() + } + + func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + 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 testCmdCtrlWPromptsBeforeClosingWindow() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let targetWindow = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + var promptedWindow: NSWindow? + appDelegate.debugCloseMainWindowConfirmationHandler = { candidate in + promptedWindow = candidate + return false + } + + guard let event = makeKeyDownEvent( + key: "w", + modifiers: [.command, .control], + keyCode: 13, + windowNumber: targetWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+Ctrl+W 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)) + + XCTAssertTrue(promptedWindow === targetWindow, "Cmd+Ctrl+W should prompt for the target main window") + XCTAssertNotNil(self.window(withId: windowId), "Cancelling the confirmation should keep the window open") + } + + func testCmdCtrlWClosesWindowAfterConfirmation() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + guard let targetWindow = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } + + guard let event = makeKeyDownEvent( + key: "w", + modifiers: [.command, .control], + keyCode: 13, + windowNumber: targetWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+Ctrl+W 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)) + + XCTAssertNil(self.window(withId: windowId), "Confirming Cmd+Ctrl+W should close the window") + } + + func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut(action: .showNotifications) { + // Dvorak: physical ANSI "I" key can produce the character "c". + // This should behave like Cmd+C (copy), not match the Cmd+I app shortcut. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 34 // kVK_ANSI_I + ) else { + XCTFail("Failed to construct Dvorak Cmd+C event on physical ANSI I key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdPhysicalPWithDvorakCharactersDoesNotTriggerCommandPaletteSwitcher() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let switcherExpectation = expectation(description: "Cmd+L should not request command palette switcher") + switcherExpectation.isInverted = true + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + // Dvorak: physical ANSI "P" key can produce "l". + // This should behave as Cmd+L, not as physical Cmd+P. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "l", + charactersIgnoringModifiers: "l", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Dvorak Cmd+L event on physical ANSI P key") + return + } + +#if DEBUG + _ = appDelegate.debugHandleCustomShortcut(event: event) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPWithCapsLockStillTriggersCommandPaletteSwitcher() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let switcherExpectation = expectation(description: "Cmd+P with Caps Lock should request command palette switcher") + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .capsLock], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "p", + charactersIgnoringModifiers: "p", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P + Caps Lock event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPFallsBackToANSIKeyCodeWhenCharactersAndLayoutTranslationAreUnavailable() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.shortcutLayoutCharacterProvider = { _, _ in nil } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + let switcherExpectation = expectation(description: "Cmd+P with unavailable characters should request command palette switcher") + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P event with unavailable characters") + return + } + + XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event)) + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPDoesNotFallbackToANSIKeyCodeWhenLayoutTranslationProvidesDifferentLetter() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.shortcutLayoutCharacterProvider = { _, _ in "b" } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + let switcherExpectation = expectation(description: "Non-P layout translation should not request command palette switcher") + switcherExpectation.isInverted = true + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P event with unavailable characters") + return + } + + _ = appDelegate.handleBrowserSurfaceKeyEquivalent(event) + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPFallsBackToCommandAwareLayoutTranslationWhenCharactersAreUnavailable() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.shortcutLayoutCharacterProvider = { keyCode, modifierFlags in + guard keyCode == 35 else { return nil } // kVK_ANSI_P + return modifierFlags.contains(.command) ? "p" : "r" + } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + let switcherExpectation = expectation(description: "Command-aware layout translation should request command palette switcher") + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P event with unavailable characters") + return + } + + XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event)) + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdShiftPhysicalPWithDvorakCharactersDoesNotTriggerCommandPalette() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let paletteExpectation = expectation(description: "Cmd+Shift+L should not request command palette") + paletteExpectation.isInverted = true + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteRequested, + object: nil, + queue: nil + ) { _ in + paletteExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + // Dvorak: physical ANSI "P" key can produce "l". + // This should behave as Cmd+Shift+L, not as physical Cmd+Shift+P. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "l", + charactersIgnoringModifiers: "l", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Dvorak Cmd+Shift+L event on physical ANSI P key") + return + } + +#if DEBUG + _ = appDelegate.debugHandleCustomShortcut(event: event) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [paletteExpectation], timeout: 0.15) + } + + func testCmdOptionPhysicalTWithDvorakCharactersDoesNotTriggerCloseOtherTabsShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + // Dvorak: physical ANSI "T" key can produce "y". + // This should not match the Cmd+Option+T app shortcut. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .option], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "y", + charactersIgnoringModifiers: "y", + isARepeat: false, + keyCode: 17 // kVK_ANSI_T + ) else { + XCTFail("Failed to construct Dvorak Cmd+Option+Y event on physical ANSI T key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window and workspace") + return + } + + let panelCountBefore = workspace.panels.count + + // Dvorak: physical ANSI "W" key can produce ",". + // This should not match the Cmd+W close-panel shortcut. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: ",", + charactersIgnoringModifiers: ",", + isARepeat: false, + keyCode: 13 // kVK_ANSI_W + ) else { + XCTFail("Failed to construct Dvorak Cmd+, event on physical ANSI W key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertEqual(workspace.panels.count, panelCountBefore) + } + + func testCmdIStillTriggersShowNotificationsShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut(action: .showNotifications) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "i", + charactersIgnoringModifiers: "i", + isARepeat: false, + keyCode: 34 // kVK_ANSI_I + ) else { + XCTFail("Failed to construct Cmd+I event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdUnshiftedSymbolDoesNotMatchDigitShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "8", command: true, shift: false, option: false, control: false) + ) { + // Some non-US layouts can produce "*" without Shift. + // This must not be coerced into "8" for a Cmd+8 shortcut match. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct Cmd+* event") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdDigitShortcutFallsBackByKeyCodeOnSymbolFirstLayouts() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) + ) { + // Symbol-first layouts (for example AZERTY) can report "&" for the ANSI 1 key. + // Cmd+1 shortcuts should still match via keyCode fallback in this case. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "&", + charactersIgnoringModifiers: "&", + isARepeat: false, + keyCode: 18 // kVK_ANSI_1 + ) else { + XCTFail("Failed to construct Cmd+& event on ANSI 1 key") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftNonDigitKeySymbolDoesNotMatchShiftedDigitShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false) + ) { + // Avoid unrelated default Cmd+Shift+] handling for this assertion. + withTemporaryShortcut( + action: .nextSurface, + shortcut: StoredShortcut(key: "x", command: true, shift: true, option: false, control: false) + ) { + // On some non-US layouts, Shift+RightBracket can produce "*". + // This must not be interpreted as Shift+8. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct Cmd+Shift+* event from non-digit key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + } + + func testCmdShiftDigitShortcutMatchesShiftedDigitKey() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false) + ) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 28 // kVK_ANSI_8 + ) else { + XCTFail("Failed to construct Cmd+Shift+8 event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftQuestionMarkMatchesSlashShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .triggerFlash, + shortcut: StoredShortcut(key: "/", command: true, shift: true, option: false, control: false) + ) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "?", + charactersIgnoringModifiers: "?", + isARepeat: false, + keyCode: 44 // kVK_ANSI_Slash + ) else { + XCTFail("Failed to construct Cmd+Shift+/ event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftISOAngleBracketDoesNotMatchCommaShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) + ) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "<", + charactersIgnoringModifiers: "<", + isARepeat: false, + keyCode: 10 // kVK_ISO_Section + ) else { + XCTFail("Failed to construct Cmd+Shift+< event from ISO key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftRightBracketCanFallbackByKeyCodeOnNonUSLayouts() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut(action: .nextSurface) { + // Non-US layouts can report "*" (or other symbols) for kVK_ANSI_RightBracket with Shift. + // Shortcut matching should still allow Cmd+Shift+] via keyCode fallback. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct non-US Cmd+Shift+] event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdPhysicalOWithDvorakCharactersTriggersRenameTabShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let renameTabExpectation = expectation(description: "Expected rename tab request for semantic Cmd+R") + var observedRenameTabWindow: NSWindow? + let renameTabToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRenameTabRequested, + object: nil, + queue: nil + ) { notification in + observedRenameTabWindow = notification.object as? NSWindow + renameTabExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(renameTabToken) } + + let switcherExpectation = expectation(description: "Cmd+R should not trigger command palette switcher") + switcherExpectation.isInverted = true + let switcherToken = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(switcherToken) } + + withTemporaryShortcut(action: .renameTab) { + // Dvorak: physical ANSI "O" key can produce "r". + // This should behave as semantic Cmd+R (rename tab), not Cmd+P. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "r", + charactersIgnoringModifiers: "r", + isARepeat: false, + keyCode: 31 // kVK_ANSI_O + ) else { + XCTFail("Failed to construct Dvorak Cmd+R event on physical ANSI O key") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + wait(for: [renameTabExpectation, switcherExpectation], timeout: 1.0) + XCTAssertEqual(observedRenameTabWindow?.windowNumber, window.windowNumber) + } + + func testCmdPhysicalRWithDvorakCharactersTriggersCommandPaletteSwitcher() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let switcherExpectation = expectation(description: "Expected command palette switcher request for semantic Cmd+P") + var observedSwitcherWindow: NSWindow? + let switcherToken = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { notification in + observedSwitcherWindow = notification.object as? NSWindow + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(switcherToken) } + + let renameTabExpectation = expectation(description: "Physical R on Dvorak should not trigger rename tab") + renameTabExpectation.isInverted = true + let renameTabToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRenameTabRequested, + object: nil, + queue: nil + ) { _ in + renameTabExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(renameTabToken) } + + // Dvorak: physical ANSI "R" key can produce "p". + // This should behave as semantic Cmd+P (palette switcher), not Cmd+R. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "p", + charactersIgnoringModifiers: "p", + isARepeat: false, + keyCode: 15 // kVK_ANSI_R + ) else { + XCTFail("Failed to construct Dvorak Cmd+P event on physical ANSI R key") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation, renameTabExpectation], timeout: 1.0) + XCTAssertEqual(observedSwitcherWindow?.windowNumber, window.windowNumber) + } + + func testCmdShiftRRequestsRenameWorkspaceInCommandPalette() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let workspaceExpectation = expectation(description: "Expected command palette rename workspace notification") + var observedWorkspaceWindow: NSWindow? + var didObserveWorkspaceNotification = false + let workspaceToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRenameWorkspaceRequested, + object: nil, + queue: nil + ) { notification in + guard !didObserveWorkspaceNotification else { return } + didObserveWorkspaceNotification = true + observedWorkspaceWindow = notification.object as? NSWindow + workspaceExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(workspaceToken) } + + let renameTabExpectation = expectation(description: "Rename tab notification should not fire for Cmd+Shift+R") + renameTabExpectation.isInverted = true + let renameTabToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRenameTabRequested, + object: nil, + queue: nil + ) { _ in + renameTabExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(renameTabToken) } + + guard let event = makeKeyDownEvent( + key: "r", + modifiers: [.command, .shift], + keyCode: 15, // kVK_ANSI_R + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+Shift+R event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [workspaceExpectation, renameTabExpectation], timeout: 1.0) + XCTAssertEqual(observedWorkspaceWindow?.windowNumber, window.windowNumber) + } + + func testEscapeDismissesVisibleCommandPaletteAndIsConsumed() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { + appDelegate.setCommandPaletteVisible(false, for: window) + } + + let dismissExpectation = expectation(description: "Expected command palette toggle notification for Escape dismiss") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let event = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, // kVK_Escape + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeDoesNotDismissCommandPaletteWhenInputHasMarkedText() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let fieldEditor = CommandPaletteMarkedTextFieldEditor(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) + fieldEditor.isFieldEditor = true + fieldEditor.hasMarkedTextForTesting = true + window.contentView?.addSubview(fieldEditor) + XCTAssertTrue(window.makeFirstResponder(fieldEditor)) + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { + appDelegate.setCommandPaletteVisible(false, for: window) + fieldEditor.removeFromSuperview() + } + + let dismissExpectation = expectation( + description: "Escape should not dismiss command palette while IME marked text is active" + ) + dismissExpectation.isInverted = true + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + guard let dismissWindow = notification.object as? NSWindow, + dismissWindow.windowNumber == window.windowNumber else { return } + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: escapeEvent), + "Escape should pass through to IME composition instead of dismissing command palette" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 0.2) + } + + func testEscapeDismissesCommandPaletteWhenVisibilitySyncLagsAfterOpenRequest() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let dismissExpectation = expectation(description: "Expected command palette dismiss notification for Escape") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + +#if DEBUG + appDelegate.debugMarkCommandPaletteOpenPending(window: window) +#else + XCTFail("debugMarkCommandPaletteOpenPending is only available in DEBUG") +#endif + + // Simulate a visibility sync lag/race where AppDelegate does not yet know the palette is open. + appDelegate.setCommandPaletteVisible(false, for: window) + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: escapeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeDismissesCommandPaletteWhenVisibilityStateStaysStalePastInitialPendingWindow() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + +#if DEBUG + XCTAssertTrue( + appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 1.3), + "Expected to backdate pending-open age for stale visibility test" + ) +#else + XCTFail("debugSetCommandPalettePendingOpenAge is only available in DEBUG") +#endif + + // Simulate stale app-level visibility bookkeeping. + appDelegate.setCommandPaletteVisible(false, for: window) + + let dismissExpectation = expectation(description: "Escape should dismiss stale-state command palette after delay") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: escapeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeDismissesCommandPaletteWhenVisibilityStateRemainsStaleForExtendedDelay() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + +#if DEBUG + XCTAssertTrue( + appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 6.25), + "Expected to backdate pending-open age for extended stale visibility test" + ) +#else + XCTFail("debugSetCommandPalettePendingOpenAge is only available in DEBUG") +#endif + + // Simulate stale app-level visibility bookkeeping for a longer user delay. + appDelegate.setCommandPaletteVisible(false, for: window) + + let dismissExpectation = expectation(description: "Escape should dismiss stale-state command palette after extended delay") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: escapeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeDoesNotConsumeWhenMenuTriggeredPendingOpenStateExpires() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + +#if DEBUG + XCTAssertTrue( + appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 20.0), + "Expected to seed an expired pending-open request state" + ) +#else + XCTFail("debugSetCommandPalettePendingOpenAge is only available in DEBUG") +#endif + + appDelegate.setCommandPaletteVisible(false, for: window) + + let dismissExpectation = expectation(description: "No dismiss notification for expired pending-open state") + dismissExpectation.isInverted = true + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { _ in + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: escapeEvent), + "Escape should pass through once pending-open grace has expired" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 0.2) + } + + func testEscapeDismissesMenuTriggeredCommandPaletteWhenVisibilitySyncIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + // Reproduce the menu-command path (Cmd+Shift+P/Cmd+P) routed via AppDelegate. + appDelegate.requestCommandPaletteCommands( + preferredWindow: window, + source: "test.menuCommandPalette" + ) + // Simulate delayed/stale visibility sync from SwiftUI overlay state. + appDelegate.setCommandPaletteVisible(false, for: window) +#if DEBUG + XCTAssertTrue( + appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 0.1), + "Expected deterministic pending-open state for menu-triggered stale-visibility path" + ) +#else + XCTFail("debugSetCommandPalettePendingOpenAge is only available in DEBUG") +#endif + + let dismissExpectation = expectation(description: "Expected command palette dismiss notification for menu-triggered stale visibility") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleCustomShortcut(event: escapeEvent), + "Escape should still be consumed for menu-triggered command palette opens" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeRepeatIsConsumedImmediatelyAfterPaletteDismiss() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { + appDelegate.setCommandPaletteVisible(false, for: window) + } + + guard let firstEscape = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct first Escape event") + return + } + + guard let repeatedEscape = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber, + isARepeat: true + ) else { + XCTFail("Failed to construct repeated Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: firstEscape)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + // Simulate the palette overlay synchronizing to closed state while the Escape key is still held. + appDelegate.setCommandPaletteVisible(false, for: window) + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleCustomShortcut(event: repeatedEscape), + "Repeated Escape immediately after dismiss should be consumed to prevent terminal passthrough" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + func testEscapeKeyUpIsConsumedAfterPaletteDismissToPreventTerminalLeak() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { + appDelegate.setCommandPaletteVisible(false, for: window) + } + + guard let escapeKeyDown = makeKeyEvent( + type: .keyDown, + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape keyDown event") + return + } + + guard let escapeKeyUp = makeKeyEvent( + type: .keyUp, + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape keyUp event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleShortcutMonitorEvent(event: escapeKeyDown)) +#else + XCTFail("debugHandleShortcutMonitorEvent is only available in DEBUG") +#endif + + // Simulate the palette overlay synchronizing to closed state before Escape key-up arrives. + appDelegate.setCommandPaletteVisible(false, for: window) + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleShortcutMonitorEvent(event: escapeKeyUp), + "Escape keyUp after palette dismiss should be consumed to prevent terminal passthrough" + ) +#else + XCTFail("debugHandleShortcutMonitorEvent is only available in DEBUG") +#endif + } + + func testEscapeKeyUpIsConsumedAfterCmdPSwitcherDismiss() { + assertEscapeKeyUpIsConsumedAfterCommandPaletteOpenRequest { appDelegate, window in + appDelegate.requestCommandPaletteSwitcher( + preferredWindow: window, + source: "test.cmdP" + ) + } + } + + func testEscapeKeyUpIsConsumedAfterCmdShiftPCommandsDismiss() { + assertEscapeKeyUpIsConsumedAfterCommandPaletteOpenRequest { appDelegate, window in + appDelegate.requestCommandPaletteCommands( + preferredWindow: window, + source: "test.cmdShiftP" + ) + } + } + + func testEscapeDoesNotDismissPaletteInDifferentWindow() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let paletteWindowId = appDelegate.createMainWindow() + let eventWindowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: paletteWindowId) + closeWindow(withId: eventWindowId) + } + + guard let paletteWindow = window(withId: paletteWindowId), + let eventWindow = window(withId: eventWindowId) else { + XCTFail("Expected both test windows") + return + } + + appDelegate.setCommandPaletteVisible(true, for: paletteWindow) + defer { + appDelegate.setCommandPaletteVisible(false, for: paletteWindow) + } + + let dismissExpectation = expectation(description: "Escape in another window should not dismiss palette") + dismissExpectation.isInverted = true + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { _ in + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: eventWindow.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: escapeEvent), + "Escape should remain scoped to the event window" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 0.2) + } + + func testCmdDigitDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + _ = firstManager.addTab(select: true) + _ = secondManager.addTab(select: true) + guard let firstSelectedBefore = firstManager.selectedTabId, + let secondSelectedBefore = secondManager.selectedTabId else { + XCTFail("Expected selected tabs in both windows") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + // Force stale app-level manager to first window while keyboard event + // references no known window. + appDelegate.tabManager = firstManager + + guard let event = makeKeyDownEvent( + key: "1", + modifiers: [.command], + keyCode: 18, + windowNumber: Int.max + ) else { + XCTFail("Failed to construct Cmd+1 event") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Unresolved event window must not route Cmd+1 into stale manager") + XCTAssertEqual(secondManager.selectedTabId, secondSelectedBefore, "Unresolved event window must not route Cmd+1 into key/main fallback manager") + XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") + } + + func testCmdNDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + appDelegate.tabManager = firstManager + + guard let event = makeKeyDownEvent( + key: "n", + modifiers: [.command], + keyCode: 45, + windowNumber: Int.max + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Unresolved event window must not create workspace in stale manager") + XCTAssertEqual(secondManager.tabs.count, secondCount, "Unresolved event window must not create workspace in fallback window") + XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") + } + + func testCmdShiftMReturnsFalseWhenNoFocusedTerminalCanHandle() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + // Force unresolved shortcut routing context and no active manager. + appDelegate.tabManager = nil + + guard let event = makeKeyDownEvent( + key: "m", + modifiers: [.command, .shift], + keyCode: 46, // kVK_ANSI_M + windowNumber: Int.max + ) else { + XCTFail("Failed to construct Cmd+Shift+M event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: event), + "Cmd+Shift+M should not be consumed when no terminal can toggle copy mode" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() { + var showFallbackSettingsWindowCallCount = 0 + var activateApplicationCallCount = 0 + var receivedNavigationTargets: [SettingsNavigationTarget?] = [] + + AppDelegate.presentPreferencesWindow( + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTargets.append(navigationTarget) + showFallbackSettingsWindowCallCount += 1 + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(showFallbackSettingsWindowCallCount, 1) + XCTAssertEqual(activateApplicationCallCount, 1) + XCTAssertEqual(receivedNavigationTargets, [nil]) + } + + func testPresentPreferencesWindowSupportsRepeatedCalls() { + var showFallbackSettingsWindowCallCount = 0 + var activateApplicationCallCount = 0 + var receivedNavigationTargets: [SettingsNavigationTarget?] = [] + + AppDelegate.presentPreferencesWindow( + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTargets.append(navigationTarget) + showFallbackSettingsWindowCallCount += 1 + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + AppDelegate.presentPreferencesWindow( + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTargets.append(navigationTarget) + showFallbackSettingsWindowCallCount += 1 + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(showFallbackSettingsWindowCallCount, 2) + XCTAssertEqual(activateApplicationCallCount, 2) + XCTAssertEqual(receivedNavigationTargets, [nil, nil]) + } + + func testPresentPreferencesWindowForwardsNavigationTarget() { + var receivedNavigationTarget: SettingsNavigationTarget? + var activateApplicationCallCount = 0 + + AppDelegate.presentPreferencesWindow( + navigationTarget: .keyboardShortcuts, + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTarget = navigationTarget + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(receivedNavigationTarget, .keyboardShortcuts) + XCTAssertEqual(activateApplicationCallCount, 1) + } + + private func makeKeyDownEvent( + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int, + isARepeat: Bool = false + ) -> NSEvent? { + makeKeyEvent( + type: .keyDown, + key: key, + modifiers: modifiers, + keyCode: keyCode, + windowNumber: windowNumber, + isARepeat: isARepeat + ) + } + + private func makeKeyEvent( + type: NSEvent.EventType, + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int, + isARepeat: Bool = false + ) -> NSEvent? { + NSEvent.keyEvent( + with: type, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: isARepeat, + keyCode: keyCode + ) + } + + private func withTemporaryShortcut( + action: KeyboardShortcutSettings.Action, + shortcut: StoredShortcut? = nil, + _ body: () -> Void + ) { + let hadPersistedShortcut = UserDefaults.standard.object(forKey: action.defaultsKey) != nil + let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) + defer { + if hadPersistedShortcut { + KeyboardShortcutSettings.setShortcut(originalShortcut, for: action) + } else { + KeyboardShortcutSettings.resetShortcut(for: action) + } + } + KeyboardShortcutSettings.setShortcut(shortcut ?? action.defaultShortcut, for: action) + body() + } + + private func assertEscapeKeyUpIsConsumedAfterCommandPaletteOpenRequest( + _ openRequest: (_ appDelegate: AppDelegate, _ window: NSWindow) -> Void, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared", file: file, line: line) + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window", file: file, line: line) + return + } + + openRequest(appDelegate, window) + appDelegate.setCommandPaletteVisible(true, for: window) + + guard let escapeKeyDown = makeKeyEvent( + type: .keyDown, + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ), let escapeKeyUp = makeKeyEvent( + type: .keyUp, + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape key events", file: file, line: line) + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleShortcutMonitorEvent(event: escapeKeyDown), file: file, line: line) +#else + XCTFail("debugHandleShortcutMonitorEvent is only available in DEBUG", file: file, line: line) +#endif + + appDelegate.setCommandPaletteVisible(false, for: window) + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleShortcutMonitorEvent(event: escapeKeyUp), + "Escape keyUp should be consumed after dismiss for command palette open requests", + file: file, + line: line + ) +#else + XCTFail("debugHandleShortcutMonitorEvent is only available in DEBUG", file: file, line: line) +#endif + } + + 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)) + } +} + +private final class CommandPaletteMarkedTextFieldEditor: NSTextView { + var hasMarkedTextForTesting = false + + override func hasMarkedText() -> Bool { + hasMarkedTextForTesting + } +} diff --git a/cmuxTests/BrowserFindJavaScriptTests.swift b/cmuxTests/BrowserFindJavaScriptTests.swift new file mode 100644 index 00000000..4de1cfb4 --- /dev/null +++ b/cmuxTests/BrowserFindJavaScriptTests.swift @@ -0,0 +1,116 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class BrowserFindJavaScriptTests: XCTestCase { + + // MARK: - searchScript + + func testSearchScriptReturnsNonEmptyJavaScript() { + let js = BrowserFindJavaScript.searchScript(query: "hello") + XCTAssertFalse(js.isEmpty) + XCTAssertTrue(js.contains("hello")) + } + + func testSearchScriptEmptyQueryReturnsEarlyReturn() { + let js = BrowserFindJavaScript.searchScript(query: "") + XCTAssertTrue(js.contains("total: 0")) + } + + // MARK: - nextScript / previousScript + + func testNextScriptReturnsValidJavaScript() { + let js = BrowserFindJavaScript.nextScript() + XCTAssertFalse(js.isEmpty) + XCTAssertTrue(js.contains("__cmuxFindMatches")) + } + + func testPreviousScriptReturnsValidJavaScript() { + let js = BrowserFindJavaScript.previousScript() + XCTAssertFalse(js.isEmpty) + XCTAssertTrue(js.contains("__cmuxFindMatches")) + } + + // MARK: - clearScript + + func testClearScriptReturnsValidJavaScript() { + let js = BrowserFindJavaScript.clearScript() + XCTAssertFalse(js.isEmpty) + XCTAssertTrue(js.contains("__cmux-find")) + } + + // MARK: - jsStringEscape + + func testEscapesDoubleQuotes() { + let result = BrowserFindJavaScript.jsStringEscape(#"say "hello""#) + XCTAssertEqual(result, #"say \"hello\""#) + } + + func testEscapesBackslashes() { + let result = BrowserFindJavaScript.jsStringEscape(#"path\to\file"#) + XCTAssertEqual(result, #"path\\to\\file"#) + } + + func testEscapesNewlines() { + let result = BrowserFindJavaScript.jsStringEscape("line1\nline2") + XCTAssertEqual(result, "line1\\nline2") + } + + func testEscapesCarriageReturns() { + let result = BrowserFindJavaScript.jsStringEscape("line1\rline2") + XCTAssertEqual(result, "line1\\rline2") + } + + func testEscapesTabs() { + let result = BrowserFindJavaScript.jsStringEscape("col1\tcol2") + XCTAssertEqual(result, "col1\\tcol2") + } + + func testPlainTextPassesThrough() { + let result = BrowserFindJavaScript.jsStringEscape("hello world 123") + XCTAssertEqual(result, "hello world 123") + } + + func testJapaneseTextPassesThrough() { + let result = BrowserFindJavaScript.jsStringEscape("こんにちは") + XCTAssertEqual(result, "こんにちは") + } + + func testMixedSpecialCharacters() { + let result = BrowserFindJavaScript.jsStringEscape(#"a\"b\nc"#) + XCTAssertEqual(result, #"a\\\"b\\nc"#) + } + + func testEscapesNullByte() { + let result = BrowserFindJavaScript.jsStringEscape("a\0b") + XCTAssertEqual(result, "a\\0b") + } + + func testEscapesLineSeparator() { + let result = BrowserFindJavaScript.jsStringEscape("a\u{2028}b") + XCTAssertEqual(result, "a\\u2028b") + } + + func testEscapesParagraphSeparator() { + let result = BrowserFindJavaScript.jsStringEscape("a\u{2029}b") + XCTAssertEqual(result, "a\\u2029b") + } + + // MARK: - searchScript escaping integration + + func testSearchScriptEscapesQueryInOutput() { + let js = BrowserFindJavaScript.searchScript(query: #"test"injection"#) + // The double quote should be escaped, not breaking the JS string literal. + XCTAssertTrue(js.contains(#"test\"injection"#)) + XCTAssertFalse(js.contains(#"test"injection"#)) + } + + func testSearchScriptHandlesLineSeparator() { + let js = BrowserFindJavaScript.searchScript(query: "test\u{2028}break") + XCTAssertTrue(js.contains("\\u2028")) + } +} diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 4191c7fc..473b11e7 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -7,43 +7,6 @@ import AppKit @testable import cmux #endif -// MARK: - Test helpers - -/// Helper to make `NSApp.currentEvent` non-nil for insertText calls. -/// NSTextInputClient.insertText guards on currentEvent because it should -/// only fire during actual key event processing. In tests we simulate this -/// by posting and immediately processing a synthetic key event. -private func withSyntheticCurrentEvent(_ body: () -> Void) { - _ = NSApplication.shared // ensure NSApp exists - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - characters: "", - charactersIgnoringModifiers: "", - isARepeat: false, - keyCode: 0 - ) else { - body() - return - } - NSApp.postEvent(event, atStart: true) - // Process the event so that currentEvent becomes non-nil. - // Use a short timeout since we just posted the event. - if let posted = NSApp.nextEvent(matching: .keyDown, until: Date(timeIntervalSinceNow: 0.05), inMode: .default, dequeue: true) { - // We're now inside event processing; currentEvent should be set. - // However, currentEvent is only set during sendEvent. We need to - // actually invoke sendEvent. Since we can't do that cleanly in a - // unit test, we use a different approach: call insertText indirectly - // via a direct test of the accumulator + unmarkText path. - _ = posted - } - body() -} - // MARK: - NSTextInputClient protocol: marked text (preedit) lifecycle /// Tests that the GhosttyNSView NSTextInputClient implementation correctly @@ -106,6 +69,30 @@ final class CJKIMEMarkedTextTests: XCTestCase { view.setKeyTextAccumulatorForTesting(nil) } + /// Third-party voice input apps often commit text outside an active keyDown + /// event. `insertText` should still clear marked text in that path. + func testInsertTextWithoutCurrentEventClearsMarkedText() { + let view = GhosttyNSView(frame: .zero) + + view.setMarkedText("한", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0)) + XCTAssertTrue(view.hasMarkedText()) + + view.insertText("한", replacementRange: NSRange(location: NSNotFound, length: 0)) + XCTAssertFalse(view.hasMarkedText(), "insertText should clear marked text even without an active currentEvent") + } + + /// The responder-chain `insertText:` action (single argument) should route + /// to NSTextInputClient insertion so external text-injection tools work. + func testResponderChainInsertTextSelectorClearsMarkedText() { + let view = GhosttyNSView(frame: .zero) + + view.setMarkedText("ni", selectedRange: NSRange(location: 2, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0)) + XCTAssertTrue(view.hasMarkedText()) + + view.insertText("你") + XCTAssertFalse(view.hasMarkedText(), "single-argument insertText should follow the same commit path") + } + // MARK: - Chinese (中文) pinyin candidate selection /// Chinese pinyin IME types Roman letters as marked text, then the user @@ -761,3 +748,186 @@ final class CJKIMEKeyTextAccumulatorTests: XCTestCase { XCTAssertNil(view.keyTextAccumulatorForTesting) } } + +// MARK: - Shift+Space fallback suppression (IME source-switch shortcut) + +final class CJKIMEShiftSpaceFallbackTests: XCTestCase { + func testSuppressesShiftSpaceFallbackWhenNoMarkedTextAndNoIMECommit() { + let view = GhosttyNSView(frame: .zero) + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: false, + keyCode: 49 + ) else { + XCTFail("Failed to create Shift+Space event") + return + } + + XCTAssertTrue( + view.shouldSuppressShiftSpaceFallbackTextForTesting(event: event, markedTextBefore: false), + "Shift+Space should suppress synthesized space fallback when IME did not commit text" + ) + } + + func testDoesNotSuppressRegularSpaceFallback() { + let view = GhosttyNSView(frame: .zero) + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: false, + keyCode: 49 + ) else { + XCTFail("Failed to create Space event") + return + } + + XCTAssertFalse( + view.shouldSuppressShiftSpaceFallbackTextForTesting(event: event, markedTextBefore: false), + "Only Shift+Space should be suppressed" + ) + } +} + +// MARK: - Space release regression (Codex hold-to-talk in cmux) + +@MainActor +final class GhosttySpaceReleaseRegressionTests: XCTestCase { + func testSyntheticSpaceReleaseCarriesUnshiftedCodepoint() { + _ = NSApplication.shared + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil + window.orderOut(nil) + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + var releaseEvent: ghostty_input_key_s? + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + if keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 49 { + releaseEvent = keyEvent + } + } + + let sent = hostedView.debugSendSyntheticKeyPressAndReleaseForUITest( + characters: " ", + charactersIgnoringModifiers: " ", + keyCode: 49 + ) + XCTAssertTrue(sent, "Expected synthetic Space key press/release to be dispatched") + + guard let releaseEvent else { + XCTFail("Expected to capture synthetic Space key release event") + return + } + + XCTAssertEqual(releaseEvent.action, GHOSTTY_ACTION_RELEASE) + XCTAssertEqual(releaseEvent.keycode, 49) + XCTAssertEqual(releaseEvent.unshifted_codepoint, " ".unicodeScalars.first!.value) + XCTAssertEqual(releaseEvent.consumed_mods.rawValue, GHOSTTY_MODS_NONE.rawValue) + XCTAssertFalse(releaseEvent.composing) + XCTAssertNil(releaseEvent.text) + } +} + +final class GhosttyBackquoteRegressionTests: XCTestCase { + func testShiftBackquoteEscFallbackSendsLiteralTilde() { + _ = NSApplication.shared + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil + window.orderOut(nil) + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + var pressText: String? + var pressUnshiftedCodepoint: UInt32? + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 50 else { return } + pressUnshiftedCodepoint = keyEvent.unshifted_codepoint + if let text = keyEvent.text { + pressText = String(cString: text) + } else { + pressText = nil + } + } + + let sent = hostedView.debugSendSyntheticKeyPressAndReleaseForUITest( + characters: "\u{1B}", + charactersIgnoringModifiers: "`", + keyCode: 50, + modifierFlags: [.shift] + ) + XCTAssertTrue(sent, "Expected synthetic Shift+backquote event to be dispatched") + XCTAssertEqual(pressText, "~") + XCTAssertEqual(pressUnshiftedCodepoint, "`".unicodeScalars.first?.value) + } +} diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index cc8f5395..580466bd 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1,7 +1,11 @@ import XCTest import AppKit +import SwiftUI import WebKit +import SwiftUI import ObjectiveC.runtime +import Bonsplit +import UserNotifications #if canImport(cmux_DEV) @testable import cmux_DEV @@ -52,6 +56,52 @@ private func installCmuxUnitTestInspectorOverride() { cmuxUnitTestInspectorOverrideInstalled = true } +final class SplitShortcutTransientFocusGuardTests: XCTestCase { + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsDetached() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: false + ) + ) + } + + func testAllowsWhenFirstResponderFallsBackButGeometryIsHealthy() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testAllowsWhenFirstResponderIsTerminalEvenIfViewIsTiny() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: false, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } +} + final class CmuxWebViewKeyEquivalentTests: XCTestCase { private final class ActionSpy: NSObject { private(set) var invoked: Bool = false @@ -61,6 +111,59 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + private final class WindowCyclingActionSpy: NSObject { + weak var firstWindow: NSWindow? + weak var secondWindow: NSWindow? + private(set) var invocationCount = 0 + + @objc func cycleWindow(_ sender: Any?) { + invocationCount += 1 + guard let firstWindow, let secondWindow else { return } + + if NSApp.keyWindow === firstWindow { + secondWindow.makeKeyAndOrderFront(nil) + } else { + firstWindow.makeKeyAndOrderFront(nil) + } + } + } + + private final class FirstResponderView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + private final class DelegateProbeTextView: NSTextView { + private(set) var delegateReadCount = 0 + + override var delegate: NSTextViewDelegate? { + get { + delegateReadCount += 1 + return super.delegate + } + set { + super.delegate = newValue + } + } + } + + private final class FieldEditorProbeTextView: NSTextView { + private(set) var delegateReadCount = 0 + + override var delegate: NSTextViewDelegate? { + get { + delegateReadCount += 1 + return super.delegate + } + set { + super.delegate = newValue + } + } + + override var isFieldEditor: Bool { + get { true } + set {} + } + } func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() { let spy = ActionSpy() installMenu(spy: spy, key: "n", modifiers: [.command]) @@ -97,15 +200,639 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { XCTAssertTrue(spy.invoked) } + func testReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: []) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 36) // kVK_Return + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: [.command]) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + func testKeypadEnterDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: []) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 76) // kVK_ANSI_KeypadEnter + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + @MainActor + func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(webView)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(webView.becomeFirstResponder()) + + _ = window.makeFirstResponder(webView) + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView)) + } + } + + @MainActor + func testPointerFocusAllowanceCanTemporarilyOverrideBlockedFirstResponderAcquisition() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(webView.becomeFirstResponder(), "Expected focus to stay blocked by policy") + + webView.withPointerFocusAllowance { + XCTAssertTrue(webView.becomeFirstResponder(), "Expected explicit pointer intent to bypass policy") + } + + _ = window.makeFirstResponder(nil) + XCTAssertFalse(webView.becomeFirstResponder(), "Expected pointer allowance to be temporary") + } + + @MainActor + func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(descendant)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(window.makeFirstResponder(descendant)) + + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView)) + } + } + + @MainActor + func testWindowFirstResponderGuardAllowsDescendantDuringPointerFocusAllowance() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus outside pointer allowance") + + _ = window.makeFirstResponder(nil) + webView.withPointerFocusAllowance { + XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer allowance to bypass guard") + } + + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer allowance to remain temporary") + } + + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusWhenPolicyIsBlocked() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context") + + let timestamp = ProcessInfo.processInfo.systemUptime + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 5, y: 5), + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant) + _ = window.makeFirstResponder(nil) + XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer click context to bypass blocked policy") + + AppDelegate.clearWindowFirstResponderGuardTesting() + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context") + } + + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromPortalHostedInspectorSibling() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + + window.makeKeyAndOrderFront(nil) + defer { + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: host.bounds) + slot.autoresizingMask = [.width, .height] + host.addSubview(slot) + + let webView = CmuxWebView(frame: slot.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + slot.addSubview(webView) + + let inspector = FirstResponderView(frame: NSRect(x: 440, y: 0, width: 200, height: slot.bounds.height)) + inspector.autoresizingMask = [.minXMargin, .height] + slot.addSubview(inspector) + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse( + window.makeFirstResponder(inspector), + "Expected portal-hosted inspector focus to stay blocked without pointer click context" + ) + + let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) + let pointInWindow = inspector.convert(pointInInspector, to: nil) + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: pointInWindow, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil) + _ = window.makeFirstResponder(nil) + XCTAssertTrue( + window.makeFirstResponder(inspector), + "Expected portal-hosted inspector click to bypass blocked policy using the overlay hit target" + ) + } + + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromBoundPortalInspectorSiblingWhenHitTestMisses() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + + let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 480, height: 260)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + + defer { + BrowserWindowPortalRegistry.detach(webView: webView) + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected bound portal slot") + return + } + + let inspector = FirstResponderView(frame: NSRect(x: 320, y: 0, width: 160, height: slot.bounds.height)) + inspector.autoresizingMask = [.minXMargin, .height] + slot.addSubview(inspector) + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse( + window.makeFirstResponder(inspector), + "Expected bound portal inspector focus to stay blocked without pointer click context" + ) + + let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) + let pointInWindow = inspector.convert(pointInInspector, to: nil) + XCTAssertTrue( + BrowserWindowPortalRegistry.webViewAtWindowPoint(pointInWindow, in: window) === webView, + "Expected portal registry to resolve the owning web view from a click inside inspector chrome" + ) + + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: pointInWindow, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil) + _ = window.makeFirstResponder(nil) + XCTAssertTrue( + window.makeFirstResponder(inspector), + "Expected bound portal inspector click to bypass blocked policy through portal registry fallback" + ) + } + + @MainActor + func testWindowFirstResponderGuardAvoidsTextViewDelegateLookupForWebViewResolution() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let textView = DelegateProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 40)) + container.addSubview(textView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + _ = window.makeFirstResponder(nil) + _ = window.makeFirstResponder(textView) + + XCTAssertEqual( + textView.delegateReadCount, + 0, + "WebView ownership resolution should not touch NSTextView.delegate (unsafe-unretained in AppKit)" + ) + } + + @MainActor + func testWindowFirstResponderGuardResolvesTrackedWebViewForFieldEditorResponder() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + let fieldEditor = FieldEditorProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) + + window.makeKeyAndOrderFront(nil) + defer { + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(descendant)) + + let timestamp = ProcessInfo.processInfo.systemUptime + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 5, y: 5), + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant) + XCTAssertTrue(window.makeFirstResponder(fieldEditor)) + + AppDelegate.clearWindowFirstResponderGuardTesting() + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(window.makeFirstResponder(fieldEditor)) + XCTAssertEqual( + fieldEditor.delegateReadCount, + 0, + "Field-editor webview ownership should come from tracked associations, not NSTextView.delegate" + ) + } + + @MainActor + func testWindowFirstResponderBypassBlocksSwizzledMakeFirstResponder() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let responder = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 80, height: 40)) + container.addSubview(responder) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + _ = window.makeFirstResponder(nil) + cmuxWithWindowFirstResponderBypass { + XCTAssertFalse( + window.makeFirstResponder(responder), + "Bypass scope should block transient first-responder changes during devtools auto-restore" + ) + } + XCTAssertTrue(window.makeFirstResponder(responder)) + } + + @MainActor + func testCmdBacktickMenuActionThatChangesKeyWindowOnlyRunsOnceWhenTerminalIsFirstResponder() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let firstWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let secondWindow = NSWindow( + contentRect: NSRect(x: 40, y: 40, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame)) + let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame)) + firstWindow.contentView = firstContainer + secondWindow.contentView = secondContainer + + let firstTerminal = GhosttyNSView(frame: firstContainer.bounds) + firstTerminal.autoresizingMask = [.width, .height] + firstContainer.addSubview(firstTerminal) + + let secondTerminal = GhosttyNSView(frame: secondContainer.bounds) + secondTerminal.autoresizingMask = [.width, .height] + secondContainer.addSubview(secondTerminal) + + let spy = WindowCyclingActionSpy() + spy.firstWindow = firstWindow + spy.secondWindow = secondWindow + installMenu( + target: spy, + action: #selector(WindowCyclingActionSpy.cycleWindow(_:)), + key: "`", + modifiers: [.command] + ) + + secondWindow.orderFront(nil) + firstWindow.makeKeyAndOrderFront(nil) + defer { + secondWindow.orderOut(nil) + firstWindow.orderOut(nil) + } + + XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal)) + guard let event = makeKeyDownEvent( + key: "`", + modifiers: [.command], + keyCode: 50, + windowNumber: firstWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+` event") + return + } + + NSApp.sendEvent(event) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action") + } + + @MainActor + func testCmdBacktickDoesNotRouteDirectlyToMainMenuWhenWebViewIsFirstResponder() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let spy = ActionSpy() + installMenu( + target: spy, + action: #selector(ActionSpy.didInvoke(_:)), + key: "`", + modifiers: [.command] + ) + + window.makeKeyAndOrderFront(nil) + defer { + window.orderOut(nil) + } + + XCTAssertTrue(window.makeFirstResponder(webView)) + guard let event = makeKeyDownEvent( + key: "`", + modifiers: [.command], + keyCode: 50, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+` event") + return + } + + XCTAssertFalse(shouldRouteCommandEquivalentDirectlyToMainMenu(event)) + _ = webView.performKeyEquivalent(with: event) + XCTAssertFalse( + spy.invoked, + "CmuxWebView should not route Cmd+` directly to the menu when WebKit is first responder" + ) + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { + installMenu( + target: spy, + action: #selector(ActionSpy.didInvoke(_:)), + key: key, + modifiers: modifiers + ) + } + + private func installMenu( + target: NSObject, + action: Selector, + key: String, + modifiers: NSEvent.ModifierFlags + ) { let mainMenu = NSMenu() let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") let fileMenu = NSMenu(title: "File") - let item = NSMenuItem(title: "Test Item", action: #selector(ActionSpy.didInvoke(_:)), keyEquivalent: key) + let item = NSMenuItem(title: "Test Item", action: action, keyEquivalent: key) item.keyEquivalentModifierMask = modifiers - item.target = spy + item.target = target fileMenu.addItem(item) mainMenu.addItem(fileItem) @@ -116,13 +843,18 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { NSApp.mainMenu = mainMenu } - private func makeKeyDownEvent(key: String, modifiers: NSEvent.ModifierFlags, keyCode: UInt16) -> NSEvent? { + private func makeKeyDownEvent( + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int = 0 + ) -> NSEvent? { NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: modifiers, timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, + windowNumber: windowNumber, context: nil, characters: key, charactersIgnoringModifiers: key, @@ -132,6 +864,240 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +@MainActor +final class AppDelegateWindowContextRoutingTests: XCTestCase { + private func makeMainWindow(id: UUID) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)") + return window + } + + func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + windowB.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB) + XCTAssertTrue(app.tabManager === managerB) + + windowA.makeKeyAndOrderFront(nil) + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // Seed active manager and clear focus windows to force fallback routing. + windowA.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(app.tabManager === managerA) + windowA.orderOut(nil) + windowB.orderOut(nil) + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil) + XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.orderOut(nil) } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // SwiftUI can replace the NSWindow identifier string at runtime. + window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged") + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window) + XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed") + XCTAssertTrue(app.tabManager === manager) + } + + func testAddWorkspaceWithoutBringToFrontPreservesActiveWindowAndSelection() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + windowA.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(app.tabManager === managerA) + + let originalSelectedA = managerA.selectedTabId + let originalSelectedB = managerB.selectedTabId + let originalTabCountB = managerB.tabs.count + + let createdWorkspaceId = app.addWorkspace(windowId: windowBId, bringToFront: false) + + XCTAssertNotNil(createdWorkspaceId) + XCTAssertTrue(app.tabManager === managerA, "Expected non-focus workspace creation to preserve active window routing") + XCTAssertEqual(managerA.selectedTabId, originalSelectedA) + XCTAssertEqual(managerB.selectedTabId, originalSelectedB, "Expected background workspace creation to preserve selected tab") + XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1) + XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId })) + } +} + +@MainActor +final class AppDelegateLaunchServicesRegistrationTests: XCTestCase { + func testScheduleLaunchServicesRegistrationDefersRegisterWork() { + _ = NSApplication.shared + let app = AppDelegate() + + var scheduledWork: (@Sendable () -> Void)? + var registerCallCount = 0 + + app.scheduleLaunchServicesBundleRegistrationForTesting( + bundleURL: URL(fileURLWithPath: "/tmp/../tmp/cmux-launch-services-test.app"), + scheduler: { work in + scheduledWork = work + }, + register: { _ in + registerCallCount += 1 + return noErr + } + ) + + XCTAssertEqual(registerCallCount, 0, "Registration should not run inline on the startup call path") + XCTAssertNotNil(scheduledWork, "Registration work should be handed to the scheduler") + + scheduledWork?() + + XCTAssertEqual(registerCallCount, 1) + } +} + +final class FocusFlashPatternTests: XCTestCase { + func testFocusFlashPatternMatchesTerminalDoublePulseShape() { + XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0]) + XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1]) + XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn]) + XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001) + } + + func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() { + let segments = FocusFlashPattern.segments + XCTAssertEqual(segments.count, 4) + + XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001) + XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001) + XCTAssertEqual(segments[0].curve, .easeOut) + + XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001) + XCTAssertEqual(segments[1].curve, .easeIn) + + XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001) + XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001) + XCTAssertEqual(segments[2].curve, .easeOut) + + XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001) + XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001) + XCTAssertEqual(segments[3].curve, .easeIn) + } +} + @MainActor final class CmuxWebViewContextMenuTests: XCTestCase { private func makeRightMouseDownEvent() -> NSEvent { @@ -204,6 +1170,40 @@ final class CmuxWebViewContextMenuTests: XCTestCase { XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" }) } + + func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadImageToDisk:") + let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } + + func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadLinkToDisk:") + let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } } final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { @@ -257,9 +1257,9 @@ final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { } } -final class BrowserForcedDarkModeSettingsTests: XCTestCase { +final class BrowserThemeSettingsTests: XCTestCase { private func makeIsolatedDefaults() -> UserDefaults { - let suiteName = "BrowserForcedDarkModeSettingsTests.\(UUID().uuidString)" + let suiteName = "BrowserThemeSettingsTests.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { fatalError("Failed to create defaults suite") } @@ -273,31 +1273,176 @@ final class BrowserForcedDarkModeSettingsTests: XCTestCase { func testDefaultsMatchConfiguredFallbacks() { let defaults = makeIsolatedDefaults() XCTAssertEqual( - BrowserForcedDarkModeSettings.enabled(defaults: defaults), - BrowserForcedDarkModeSettings.defaultEnabled - ) - XCTAssertEqual( - BrowserForcedDarkModeSettings.opacity(defaults: defaults), - BrowserForcedDarkModeSettings.defaultOpacity + BrowserThemeSettings.mode(defaults: defaults), + BrowserThemeSettings.defaultMode ) } - func testOpacityIsClampedToSupportedRange() { + func testModeReadsPersistedValue() { let defaults = makeIsolatedDefaults() - defaults.set(-100.0, forKey: BrowserForcedDarkModeSettings.opacityKey) - XCTAssertEqual( - BrowserForcedDarkModeSettings.opacity(defaults: defaults), - BrowserForcedDarkModeSettings.minOpacity - ) + defaults.set(BrowserThemeMode.dark.rawValue, forKey: BrowserThemeSettings.modeKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark) - defaults.set(999.0, forKey: BrowserForcedDarkModeSettings.opacityKey) - XCTAssertEqual( - BrowserForcedDarkModeSettings.opacity(defaults: defaults), - BrowserForcedDarkModeSettings.maxOpacity - ) + defaults.set(BrowserThemeMode.light.rawValue, forKey: BrowserThemeSettings.modeKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .light) + } + + func testModeMigratesLegacyForcedDarkModeFlag() { + let defaults = makeIsolatedDefaults() + defaults.set(true, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark) + XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.dark.rawValue) + + let otherDefaults = makeIsolatedDefaults() + otherDefaults.set(false, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: otherDefaults), .system) + XCTAssertEqual(otherDefaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.system.rawValue) } } +final class BrowserPanelChromeBackgroundColorTests: XCTestCase { + func testLightModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .light) + } + + func testDarkModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .dark) + } + + private func assertResolvedColorMatchesTheme( + for colorScheme: ColorScheme, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0) + + guard + let actual = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expected = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line) + } +} + +final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { + func testLightModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) + } + + func testDarkModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) + } + + private func assertResolvedColorMatchesExpectedBlend( + for colorScheme: ColorScheme, + darkenMix: CGFloat, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) + let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground + + guard + let actual = resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expectedSRGB = expected.usingColorSpace(.sRGB), + let themeSRGB = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) + XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) + } +} + +final class SidebarActiveForegroundColorTests: XCTestCase { + func testLightAppearanceUsesBlackWithRequestedOpacity() { + guard let lightAppearance = NSAppearance(named: .aqua), + let color = sidebarActiveForegroundNSColor( + opacity: 0.8, + appAppearance: lightAppearance + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001) + } + + func testDarkAppearanceUsesWhiteWithRequestedOpacity() { + guard let darkAppearance = NSAppearance(named: .darkAqua), + let color = sidebarActiveForegroundNSColor( + opacity: 0.65, + appAppearance: darkAppearance + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } +} + +final class SidebarSelectedWorkspaceColorTests: XCTestCase { + func testLightModeUsesConfiguredSelectedWorkspaceBackgroundColor() { + guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .light).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 136.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) + } + + func testDarkModeUsesConfiguredSelectedWorkspaceBackgroundColor() { + guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 145.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) + } + + func testSelectedWorkspaceForegroundAlwaysUsesWhiteWithRequestedOpacity() { + guard let color = sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } +} final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { func testSafariDefaultShortcutForToggleDeveloperTools() { let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut @@ -318,6 +1463,671 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { } } +final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { + func testRenameTabShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.label, "Rename Tab") + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.defaultsKey, "shortcut.renameTab") + + let shortcut = KeyboardShortcutSettings.Action.renameTab.defaultShortcut + XCTAssertEqual(shortcut.key, "r") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testCloseWindowShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.label, "Close Window") + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.defaultsKey, "shortcut.closeWindow") + + let shortcut = KeyboardShortcutSettings.Action.closeWindow.defaultShortcut + XCTAssertEqual(shortcut.key, "w") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertTrue(shortcut.control) + } + + func testRenameWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.label, "Rename Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey, "shortcut.renameWorkspace") + + let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + XCTAssertEqual(shortcut.key, "r") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testRenameWorkspaceShortcutConvertsToMenuShortcut() { + let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + XCTAssertNotNil(shortcut.keyEquivalent) + XCTAssertTrue(shortcut.eventModifiers.contains(.command)) + XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) + XCTAssertFalse(shortcut.eventModifiers.contains(.option)) + XCTAssertFalse(shortcut.eventModifiers.contains(.control)) + } + + func testCloseWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.label, "Close Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey, "shortcut.closeWorkspace") + + let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + XCTAssertEqual(shortcut.key, "w") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testCloseWorkspaceShortcutConvertsToMenuShortcut() { + let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + XCTAssertNotNil(shortcut.keyEquivalent) + XCTAssertTrue(shortcut.eventModifiers.contains(.command)) + XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) + XCTAssertFalse(shortcut.eventModifiers.contains(.option)) + XCTAssertFalse(shortcut.eventModifiers.contains(.control)) + } + + func testNextPreviousWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.label, "Next Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.label, "Previous Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey, "shortcut.nextSidebarTab") + XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey, "shortcut.prevSidebarTab") + + let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + XCTAssertEqual(nextShortcut.key, "]") + XCTAssertTrue(nextShortcut.command) + XCTAssertFalse(nextShortcut.shift) + XCTAssertFalse(nextShortcut.option) + XCTAssertTrue(nextShortcut.control) + + let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + XCTAssertEqual(prevShortcut.key, "[") + XCTAssertTrue(prevShortcut.command) + XCTAssertFalse(prevShortcut.shift) + XCTAssertFalse(prevShortcut.option) + XCTAssertTrue(prevShortcut.control) + } + + func testNextPreviousWorkspaceShortcutsConvertToMenuShortcut() { + let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + XCTAssertNotNil(nextShortcut.keyEquivalent) + XCTAssertEqual(nextShortcut.menuItemKeyEquivalent, "]") + XCTAssertTrue(nextShortcut.eventModifiers.contains(.command)) + XCTAssertTrue(nextShortcut.eventModifiers.contains(.control)) + + let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + XCTAssertNotNil(prevShortcut.keyEquivalent) + XCTAssertEqual(prevShortcut.menuItemKeyEquivalent, "[") + XCTAssertTrue(prevShortcut.eventModifiers.contains(.command)) + XCTAssertTrue(prevShortcut.eventModifiers.contains(.control)) + } + + func testToggleTerminalCopyModeShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalCopyMode.label, "Toggle Terminal Copy Mode") + XCTAssertEqual( + KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultsKey, + "shortcut.toggleTerminalCopyMode" + ) + + let shortcut = KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultShortcut + XCTAssertEqual(shortcut.key, "m") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() { + XCTAssertNotNil(StoredShortcut(key: "←", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "→", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "↑", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "↓", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertEqual( + StoredShortcut(key: "\t", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent, + "\t" + ) + } + + func testShortcutDefaultsKeysRemainUnique() { + let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey) + XCTAssertEqual(Set(keys).count, keys.count) + } +} + +final class TerminalKeyboardCopyModeActionTests: XCTestCase { + func testCopyModeBypassAllowsOnlyCommandShortcuts() { + XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command])) + XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .shift])) + XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .option])) + XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option])) + XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option, .shift])) + XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.control])) + } + + func testJKWithoutSelectionScrollByLine() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 38, + charactersIgnoringModifiers: "j", + modifierFlags: [], + hasSelection: false + ), + .scrollLines(1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 40, + charactersIgnoringModifiers: "k", + modifierFlags: [], + hasSelection: false + ), + .scrollLines(-1) + ) + } + + func testCapsLockDoesNotBlockLetterMappings() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 38, + charactersIgnoringModifiers: "j", + modifierFlags: [.capsLock], + hasSelection: false + ), + .scrollLines(1) + ) + } + + func testJKWithSelectionAdjustSelection() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 38, + charactersIgnoringModifiers: "j", + modifierFlags: [], + hasSelection: true + ), + .adjustSelection(.down) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 40, + charactersIgnoringModifiers: "k", + modifierFlags: [], + hasSelection: true + ), + .adjustSelection(.up) + ) + } + + func testControlPagingSupportsPrintableAndControlCharacters() { + // Ctrl+U = half-page up (vim standard). + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{15}", + modifierFlags: [.control], + hasSelection: false + ), + .scrollHalfPage(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{04}", + modifierFlags: [.control], + hasSelection: true + ), + .adjustSelection(.pageDown) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{02}", + modifierFlags: [.control], + hasSelection: false + ), + .scrollPage(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{06}", + modifierFlags: [.control], + hasSelection: true + ), + .adjustSelection(.pageDown) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{19}", + modifierFlags: [.control], + hasSelection: false + ), + .scrollLines(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{05}", + modifierFlags: [.control], + hasSelection: true + ), + .adjustSelection(.down) + ) + } + + func testVGYMapping() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [], + hasSelection: false + ), + .startSelection + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [], + hasSelection: true + ), + .clearSelection + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 16, + charactersIgnoringModifiers: "y", + modifierFlags: [], + hasSelection: true + ), + .copyAndExit + ) + } + + func testGAndShiftGMapping() { + // Bare "g" is a prefix key (gg), not an immediate action. + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 5, + charactersIgnoringModifiers: "g", + modifierFlags: [], + hasSelection: false + ) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 5, + charactersIgnoringModifiers: "g", + modifierFlags: [.shift], + hasSelection: false + ), + .scrollToBottom + ) + } + + func testLineBoundaryPromptAndSearchMappings() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 29, + charactersIgnoringModifiers: "0", + modifierFlags: [], + hasSelection: true + ), + .adjustSelection(.beginningOfLine) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 20, + charactersIgnoringModifiers: "^", + modifierFlags: [.shift], + hasSelection: true + ), + .adjustSelection(.beginningOfLine) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 21, + charactersIgnoringModifiers: "4", + modifierFlags: [.shift], + hasSelection: true + ), + .adjustSelection(.endOfLine) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 33, + charactersIgnoringModifiers: "[", + modifierFlags: [.shift], + hasSelection: false + ), + .jumpToPrompt(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 30, + charactersIgnoringModifiers: "]", + modifierFlags: [.shift], + hasSelection: false + ), + .jumpToPrompt(1) + ) + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 21, + charactersIgnoringModifiers: "4", + modifierFlags: [], + hasSelection: true + ) + ) + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 33, + charactersIgnoringModifiers: "[", + modifierFlags: [], + hasSelection: false + ) + ) + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 30, + charactersIgnoringModifiers: "]", + modifierFlags: [], + hasSelection: false + ) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 44, + charactersIgnoringModifiers: "/", + modifierFlags: [], + hasSelection: false + ), + .startSearch + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 45, + charactersIgnoringModifiers: "n", + modifierFlags: [], + hasSelection: false + ), + .searchNext + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 45, + charactersIgnoringModifiers: "n", + modifierFlags: [.shift], + hasSelection: false + ), + .searchPrevious + ) + } + + func testShiftVMatchesVisualToggleBehavior() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [.shift], + hasSelection: false + ), + .startSelection + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [.shift], + hasSelection: true + ), + .clearSelection + ) + } + + func testEscapeAlwaysExits() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 53, + charactersIgnoringModifiers: "", + modifierFlags: [], + hasSelection: false + ), + .exit + ) + } + + func testQAlwaysExits() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 12, // kVK_ANSI_Q + charactersIgnoringModifiers: "q", + modifierFlags: [], + hasSelection: false + ), + .exit + ) + } +} + +final class TerminalKeyboardCopyModeResolveTests: XCTestCase { + private func resolve( + _ keyCode: UInt16, + chars: String, + modifiers: NSEvent.ModifierFlags = [], + hasSelection: Bool, + state: inout TerminalKeyboardCopyModeInputState + ) -> TerminalKeyboardCopyModeResolution { + terminalKeyboardCopyModeResolve( + keyCode: keyCode, + charactersIgnoringModifiers: chars, + modifierFlags: modifiers, + hasSelection: hasSelection, + state: &state + ) + } + + func testCountPrefixAppliesToMotion() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 3)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testZeroAppendsCountOrActsAsMotion() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(19, chars: "2", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(29, chars: "0", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(40, chars: "k", hasSelection: false, state: &state), .perform(.scrollLines(-1), count: 20)) + + var selectionState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(29, chars: "0", hasSelection: true, state: &selectionState), + .perform(.adjustSelection(.beginningOfLine), count: 1) + ) + } + + func testYankLineOperatorSupportsYYAndYWithCounts() { + var yyState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .consume) + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .perform(.copyLineAndExit, count: 1)) + + var countedState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(21, chars: "4", hasSelection: false, state: &countedState), .consume) + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .consume) + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .perform(.copyLineAndExit, count: 4)) + + var shiftYState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &shiftYState), .consume) + XCTAssertEqual( + resolve(16, chars: "y", modifiers: [.shift], hasSelection: false, state: &shiftYState), + .perform(.copyLineAndExit, count: 3) + ) + } + + func testPendingYankLineDoesNotSwallowNextCommand() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testSearchAndPromptMotionsUseCounts() { + var promptState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &promptState), .consume) + XCTAssertEqual( + resolve(30, chars: "]", modifiers: [.shift], hasSelection: false, state: &promptState), + .perform(.jumpToPrompt(1), count: 3) + ) + + var searchState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &searchState), .consume) + XCTAssertEqual(resolve(45, chars: "n", hasSelection: false, state: &searchState), .perform(.searchNext, count: 2)) + } + + func testInvalidKeyClearsPendingState() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(7, chars: "x", hasSelection: false, state: &state), .consume) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + // MARK: - gg (scroll to top via two-key sequence) + + func testGGScrollsToTop() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 1)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testGGWithSelectionAdjustsToHome() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .consume) + XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .perform(.adjustSelection(.home), count: 1)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testCountedGG() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(22, chars: "5", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 5)) + } + + func testPendingGCancelledByOtherKey() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testShiftGStillWorksImmediately() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(5, chars: "g", modifiers: [.shift], hasSelection: false, state: &state), + .perform(.scrollToBottom, count: 1) + ) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + // MARK: - Ctrl+U/D half-page scroll + + func testCtrlUHalfPage() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(32, chars: "u", modifiers: [.control], hasSelection: false, state: &state), + .perform(.scrollHalfPage(-1), count: 1) + ) + } + + func testCtrlDHalfPage() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(2, chars: "d", modifiers: [.control], hasSelection: false, state: &state), + .perform(.scrollHalfPage(1), count: 1) + ) + } + + func testCtrlBFullPage() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(11, chars: "b", modifiers: [.control], hasSelection: false, state: &state), + .perform(.scrollPage(-1), count: 1) + ) + } + + func testCtrlFFullPage() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(3, chars: "f", modifiers: [.control], hasSelection: false, state: &state), + .perform(.scrollPage(1), count: 1) + ) + } +} + +final class TerminalKeyboardCopyModeViewportRowTests: XCTestCase { + func testInitialViewportRowUsesImePointBaseline() { + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 24, + imeCellHeight: 24 + ), + 0 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 240, + imeCellHeight: 24 + ), + 9 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 48, + imeCellHeight: 24, + topPadding: 24 + ), + 0 + ) + } + + func testInitialViewportRowClampsBoundsAndFallsBackWhenHeightMissing() { + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 0, + imeCellHeight: 24 + ), + 0 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 9999, + imeCellHeight: 24 + ), + 23 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 123, + imeCellHeight: 0 + ), + 23 + ) + } +} + @MainActor final class BrowserDeveloperToolsConfigurationTests: XCTestCase { func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { @@ -361,9 +2171,284 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { XCTAssertEqual(panel.displayTitle, "New tab") XCTAssertFalse(panel.shouldRenderWebView) + XCTAssertTrue(panel.isShowingNewTabPage) XCTAssertNil(panel.webView.url) XCTAssertNil(panel.currentURL) } + + func testBrowserPanelLeavesNewTabPageStateWhenNavigationStarts() { + let panel = BrowserPanel(workspaceId: UUID()) + + XCTAssertTrue(panel.isShowingNewTabPage) + panel.navigate(to: URL(string: "https://example.com")!) + XCTAssertFalse(panel.isShowingNewTabPage) + } + + func testBrowserPanelThemeModeUpdatesWebViewAppearance() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.setBrowserThemeMode(.dark) + XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.darkAqua, .aqua]), .darkAqua) + + panel.setBrowserThemeMode(.light) + XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.aqua, .darkAqua]), .aqua) + + panel.setBrowserThemeMode(.system) + XCTAssertNil(panel.webView.appearance) + } + + func testBrowserPanelRefreshesUnderPageBackgroundColorWithGhosttyOpacity() { + let panel = BrowserPanel(workspaceId: UUID()) + let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) + + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: updatedColor, + GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), + ] + ) + + guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB), + let expected = updatedColor.withAlphaComponent(0.57).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible under-page background colors") + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005) + } +} + +final class GhosttyBackgroundThemeTests: XCTestCase { + func testColorClampsOpacity() { + let base = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) + + let lowerClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: -2.0) + XCTAssertEqual(lowerClamped.alphaComponent, 0.0, accuracy: 0.0001) + + let upperClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: 5.0) + XCTAssertEqual(upperClamped.alphaComponent, 1.0, accuracy: 0.0001) + } + + func testColorFromNotificationUsesBackgroundAndOpacity() { + let fallbackColor = NSColor.black + let fallbackOpacity = 1.0 + let notification = Notification( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0), + GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), + ] + ) + + let actual = GhosttyBackgroundTheme.color( + from: notification, + fallbackColor: fallbackColor, + fallbackOpacity: fallbackOpacity + ) + guard let srgb = actual.usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(srgb.redComponent, 0.18, accuracy: 0.005) + XCTAssertEqual(srgb.greenComponent, 0.29, accuracy: 0.005) + XCTAssertEqual(srgb.blueComponent, 0.44, accuracy: 0.005) + XCTAssertEqual(srgb.alphaComponent, 0.57, accuracy: 0.005) + } + + func testColorFromNotificationFallsBackWhenPayloadMissing() { + let fallbackColor = NSColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0) + let fallbackOpacity = 0.42 + let notification = Notification(name: .ghosttyDefaultBackgroundDidChange) + + let actual = GhosttyBackgroundTheme.color( + from: notification, + fallbackColor: fallbackColor, + fallbackOpacity: fallbackOpacity + ) + guard let srgb = actual.usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(srgb.redComponent, 0.12, accuracy: 0.005) + XCTAssertEqual(srgb.greenComponent, 0.34, accuracy: 0.005) + XCTAssertEqual(srgb.blueComponent, 0.56, accuracy: 0.005) + XCTAssertEqual(srgb.alphaComponent, 0.42, accuracy: 0.005) + } +} + +@MainActor +final class BrowserInsecureHTTPAlertPresentationTests: XCTestCase { + private final class BrowserInsecureHTTPAlertSpy: NSAlert { + private(set) var beginSheetModalCallCount = 0 + private(set) var runModalCallCount = 0 + var nextResponse: NSApplication.ModalResponse = .alertThirdButtonReturn + + override func beginSheetModal( + for sheetWindow: NSWindow, + completionHandler handler: ((NSApplication.ModalResponse) -> Void)? + ) { + beginSheetModalCallCount += 1 + handler?(nextResponse) + } + + override func runModal() -> NSApplication.ModalResponse { + runModalCallCount += 1 + return nextResponse + } + } + + func testInsecureHTTPPromptUsesSheetWhenWindowIsAvailable() { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.resetInsecureHTTPAlertHooksForTesting() } + + let alertSpy = BrowserInsecureHTTPAlertSpy() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + + panel.configureInsecureHTTPAlertHooksForTesting( + alertFactory: { alertSpy }, + windowProvider: { window } + ) + panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + } + + func testInsecureHTTPPromptFallsBackToRunModalWithoutWindow() { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.resetInsecureHTTPAlertHooksForTesting() } + + let alertSpy = BrowserInsecureHTTPAlertSpy() + panel.configureInsecureHTTPAlertHooksForTesting( + alertFactory: { alertSpy }, + windowProvider: { nil } + ) + panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0) + XCTAssertEqual(alertSpy.runModalCallCount, 1) + } +} + +final class BrowserNavigationNewTabDecisionTests: XCTestCase { + func testLinkActivatedCmdClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [.command], + buttonNumber: 0 + ) + ) + } + + func testLinkActivatedMiddleClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testLinkActivatedPlainLeftClickStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationMiddleClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .other, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testOtherNavigationLeftClickStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .other, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testLinkActivatedButtonFourWithoutMiddleIntentStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 4, + hasRecentMiddleClickIntent: false + ) + ) + } + + func testLinkActivatedButtonFourWithRecentMiddleIntentOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 4, + hasRecentMiddleClickIntent: true + ) + ) + } + + func testLinkActivatedUsesCurrentEventFallbackForMiddleClick() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0, + currentEventType: .otherMouseUp, + currentEventButtonNumber: 2 + ) + ) + } + + func testCurrentEventFallbackDoesNotAffectNonLinkNavigation() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .reload, + modifierFlags: [], + buttonNumber: 0, + currentEventType: .otherMouseUp, + currentEventButtonNumber: 2 + ) + ) + } + + func testNonLinkNavigationNeverForcesNewTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .reload, + modifierFlags: [.command], + buttonNumber: 2 + ) + ) + } } @MainActor @@ -408,25 +2493,242 @@ final class BrowserJavaScriptDialogDelegateTests: XCTestCase { } } +@MainActor +final class BrowserSessionHistoryRestoreTests: XCTestCase { + func testSessionNavigationHistorySnapshotUsesRestoredStacks() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.restoreSessionNavigationHistory( + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ], + currentURLString: "https://example.com/c" + ) + + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + + let snapshot = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual( + snapshot.backHistoryURLStrings, + ["https://example.com/a", "https://example.com/b"] + ) + XCTAssertEqual( + snapshot.forwardHistoryURLStrings, + ["https://example.com/d"] + ) + } + + func testSessionNavigationHistoryBackAndForwardUpdateStacks() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.restoreSessionNavigationHistory( + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ], + currentURLString: "https://example.com/c" + ) + + panel.goBack() + let afterBack = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual(afterBack.backHistoryURLStrings, ["https://example.com/a"]) + XCTAssertEqual( + afterBack.forwardHistoryURLStrings, + ["https://example.com/c", "https://example.com/d"] + ) + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + + panel.goForward() + let afterForward = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual( + afterForward.backHistoryURLStrings, + ["https://example.com/a", "https://example.com/b"] + ) + XCTAssertEqual(afterForward.forwardHistoryURLStrings, ["https://example.com/d"]) + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + } + + func testWebViewReplacementAfterProcessTerminationUpdatesInstanceIdentity() { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: URL(string: "https://example.com") + ) + let oldWebView = panel.webView + let oldInstanceID = panel.webViewInstanceID + + panel.debugSimulateWebContentProcessTermination() + + XCTAssertFalse(panel.webView === oldWebView) + XCTAssertNotEqual(panel.webViewInstanceID, oldInstanceID) + XCTAssertNotNil(panel.webView.navigationDelegate) + XCTAssertNotNil(panel.webView.uiDelegate) + } + + func testWebViewReplacementPreservesEmptyNewTabRenderState() { + let panel = BrowserPanel(workspaceId: UUID()) + XCTAssertFalse(panel.shouldRenderWebView) + + panel.debugSimulateWebContentProcessTermination() + + XCTAssertFalse(panel.shouldRenderWebView) + } + + func testResetSidebarContextClearsBrowserPanelsIntoNewTabState() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let contextPanelId = try XCTUnwrap(workspace.focusedPanelId) + let browser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + url: URL(string: "https://example.com"), + focus: false + ) + ) + + browser.restoreSessionNavigationHistory( + backHistoryURLStrings: ["https://example.com/prev"], + forwardHistoryURLStrings: ["https://example.com/next"], + currentURLString: "https://example.com/current" + ) + browser.startFind() + + workspace.statusEntries["task"] = SidebarStatusEntry(key: "task", value: "Issue #1208") + workspace.metadataBlocks["notes"] = SidebarMetadataBlock( + key: "notes", + markdown: "test", + priority: 0, + timestamp: Date() + ) + workspace.progress = SidebarProgressState(value: 0.5, label: "Loading") + workspace.updatePanelGitBranch(panelId: contextPanelId, branch: "issue-1208", isDirty: false) + workspace.updatePanelPullRequest( + panelId: contextPanelId, + number: 1208, + label: "PR", + url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")), + status: .open + ) + workspace.logEntries.append( + SidebarLogEntry( + message: "Issue #1208", + level: .info, + source: "test", + timestamp: Date() + ) + ) + workspace.surfaceListeningPorts[contextPanelId] = [3000] + workspace.recomputeListeningPorts() + + XCTAssertTrue(browser.shouldRenderWebView) + XCTAssertNotNil(browser.preferredURLStringForOmnibar()) + XCTAssertTrue(browser.canGoBack) + XCTAssertTrue(browser.canGoForward) + XCTAssertNotNil(browser.searchState) + XCTAssertFalse(workspace.statusEntries.isEmpty) + XCTAssertFalse(workspace.logEntries.isEmpty) + XCTAssertFalse(workspace.metadataBlocks.isEmpty) + XCTAssertNotNil(workspace.progress) + XCTAssertNotNil(workspace.gitBranch) + XCTAssertNotNil(workspace.pullRequest) + XCTAssertEqual(workspace.listeningPorts, [3000]) + + let priorWebView = browser.webView + let priorInstanceID = browser.webViewInstanceID + workspace.resetSidebarContext(reason: "test") + + XCTAssertTrue(workspace.statusEntries.isEmpty) + XCTAssertTrue(workspace.logEntries.isEmpty) + XCTAssertTrue(workspace.metadataBlocks.isEmpty) + XCTAssertNil(workspace.progress) + XCTAssertNil(workspace.gitBranch) + XCTAssertTrue(workspace.panelGitBranches.isEmpty) + XCTAssertNil(workspace.pullRequest) + XCTAssertTrue(workspace.panelPullRequests.isEmpty) + XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty) + XCTAssertTrue(workspace.listeningPorts.isEmpty) + XCTAssertFalse(browser.shouldRenderWebView) + XCTAssertNil(browser.preferredURLStringForOmnibar()) + XCTAssertFalse(browser.canGoBack) + XCTAssertFalse(browser.canGoForward) + XCTAssertNil(browser.searchState) + XCTAssertFalse(browser.webView === priorWebView) + XCTAssertNotEqual(browser.webViewInstanceID, priorInstanceID) + } + +} + @MainActor final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { + private final class WKInspectorProbeView: NSView { + override var acceptsFirstResponder: Bool { true } + } + private final class FakeInspector: NSObject { + enum HideBehavior { + case unsupported + case noEffect + case hides + } + + private(set) var attachCount = 0 private(set) var showCount = 0 + private(set) var hideCount = 0 private(set) var closeCount = 0 + private let hideBehavior: HideBehavior private var visible = false + private var attached = false + + init(hideBehavior: HideBehavior = .unsupported) { + self.hideBehavior = hideBehavior + super.init() + } + + override func responds(to aSelector: Selector!) -> Bool { + guard NSStringFromSelector(aSelector) == "hide" else { + return super.responds(to: aSelector) + } + return hideBehavior != .unsupported + } @objc func isVisible() -> Bool { visible } + @objc func isAttached() -> Bool { + attached + } + + @objc func attach() { + attachCount += 1 + attached = true + show() + } + @objc func show() { showCount += 1 visible = true } + @objc func hide() { + hideCount += 1 + guard hideBehavior == .hides else { return } + visible = false + } + @objc func close() { closeCount += 1 visible = false + attached = false } } @@ -435,13 +2737,43 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { installCmuxUnitTestInspectorOverride() } - private func makePanelWithInspector() -> (BrowserPanel, FakeInspector) { + private func makePanelWithInspector( + hideBehavior: FakeInspector.HideBehavior = .unsupported + ) -> (BrowserPanel, FakeInspector) { let panel = BrowserPanel(workspaceId: UUID()) - let inspector = FakeInspector() + let inspector = FakeInspector(hideBehavior: hideBehavior) panel.webView.cmuxSetUnitTestInspector(inspector) return (panel, inspector) } + private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? { + if let host = root as? WebViewRepresentable.HostContainerView { + return host + } + for subview in root.subviews { + if let host = findHostContainerView(in: subview) { + return host + } + } + return nil + } + + private func waitForDeveloperToolsTransitions() { + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + + private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? { + if let slot = root as? WindowBrowserSlotView { + return slot + } + for subview in root.subviews { + if let slot = findWindowBrowserSlotView(in: subview) { + return slot + } + } + return nil + } + func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() { let (panel, inspector) = makePanelWithInspector() @@ -523,6 +2855,50 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) } + func testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + waitForDeveloperToolsTransitions() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + } + + func testRapidToggleQueuesHideAfterOpenTransitionSettles() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + waitForDeveloperToolsTransitions() + + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 1) + } + + func testToggleDeveloperToolsFallsBackToCloseWhenHideDoesNotConcealInspector() { + let (panel, inspector) = makePanelWithInspector(hideBehavior: .noEffect) + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + + XCTAssertTrue(panel.toggleDeveloperTools()) + + XCTAssertEqual(inspector.hideCount, 1) + XCTAssertEqual(inspector.closeCount, 1) + XCTAssertFalse(panel.isDeveloperToolsVisible()) + } + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { let (panel, _) = makePanelWithInspector() @@ -533,46 +2909,307 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) } - func testWebViewDismantleSkipsDetachWhenDeveloperToolsIntentIsVisible() { + func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() { + let (panel, _) = makePanelWithInspector() + let paneId = PaneID(id: UUID()) + XCTAssertTrue(panel.showDeveloperTools()) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let anchor = NSView(frame: NSRect(x: 30, y: 30, width: 180, height: 140)) + window.contentView?.addSubview(anchor) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + XCTAssertNotNil(panel.webView.superview) + + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: true, + useLocalInlineHosting: false, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) + + XCTAssertNotNil(panel.webView.superview) + window.orderOut(nil) + } + + func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() { + let (panel, _) = makePanelWithInspector() + let paneId = PaneID(id: UUID()) + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 150)) + window.contentView?.addSubview(anchor) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + XCTAssertNotNil(panel.webView.superview) + + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: true, + useLocalInlineHosting: false, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) + + XCTAssertNotNil(panel.webView.superview) + window.orderOut(nil) + } + + func testTransientHideAttachmentPreserveDisablesForSideDockedInspectorLayout() { let (panel, _) = makePanelWithInspector() XCTAssertTrue(panel.showDeveloperTools()) - let representable = WebViewRepresentable( - panel: panel, - shouldAttachWebView: true, - shouldFocusWebView: false, - isPanelFocused: true, - portalZPriority: 0 - ) - let coordinator = representable.makeCoordinator() - coordinator.webView = panel.webView - let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) + panel.webView.frame = NSRect(x: 0, y: 0, width: 120, height: host.bounds.height) host.addSubview(panel.webView) - WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + let inspectorContainer = NSView( + frame: NSRect(x: 120, y: 0, width: host.bounds.width - 120, height: host.bounds.height) + ) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + host.addSubview(inspectorContainer) - XCTAssertTrue(panel.webView.superview === host) + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) } - func testWebViewDismantleDetachesWhenDeveloperToolsIntentIsHidden() { + func testTransientHideAttachmentPreserveStaysEnabledForBottomDockedInspectorLayout() { let (panel, _) = makePanelWithInspector() - XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.showDeveloperTools()) - let representable = WebViewRepresentable( - panel: panel, - shouldAttachWebView: true, - shouldFocusWebView: false, - isPanelFocused: true, - portalZPriority: 0 - ) - let coordinator = representable.makeCoordinator() - coordinator.webView = panel.webView - let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) + panel.webView.frame = NSRect(x: 0, y: 80, width: host.bounds.width, height: host.bounds.height - 80) host.addSubview(panel.webView) - WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + let inspectorContainer = NSView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 80)) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + host.addSubview(inspectorContainer) - XCTAssertNil(panel.webView.superview) + XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } + + func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let paneId = PaneID(id: UUID()) + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: false, + useLocalInlineHosting: true, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let visibleHosting = NSHostingView(rootView: representable) + visibleHosting.frame = contentView.bounds + visibleHosting.autoresizingMask = [.width, .height] + contentView.addSubview(visibleHosting) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + visibleHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let visibleHost = findHostContainerView(in: visibleHosting) else { + XCTFail("Expected visible local host") + return + } + guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected visible local inline slot") + return + } + + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72) + ) + inspectorView.autoresizingMask = [.width] + visibleSlot.addSubview(inspectorView) + panel.webView.frame = NSRect( + x: 0, + y: inspectorView.frame.maxY, + width: visibleSlot.bounds.width, + height: visibleSlot.bounds.height - inspectorView.frame.height + ) + visibleSlot.layoutSubtreeIfNeeded() + + let detachedRoot = NSView(frame: visibleHosting.frame) + let offWindowHosting = NSHostingView(rootView: representable) + offWindowHosting.frame = detachedRoot.bounds + offWindowHosting.autoresizingMask = [.width, .height] + detachedRoot.addSubview(offWindowHosting) + detachedRoot.layoutSubtreeIfNeeded() + offWindowHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host") + XCTAssertTrue(visibleHost.window === window) + XCTAssertTrue( + panel.webView.superview === visibleSlot, + "An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn" + ) + XCTAssertTrue( + inspectorView.superview === visibleSlot, + "An off-window replacement host should leave DevTools companion views in the visible local host" + ) + } + + func testVisibleReplacementLocalHostNormalizesBottomDockedInspectorFrames() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let paneId = PaneID(id: UUID()) + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: false, + useLocalInlineHosting: true, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let narrowHosting = NSHostingView(rootView: representable) + narrowHosting.frame = NSRect(x: 180, y: 0, width: 180, height: 240) + contentView.addSubview(narrowHosting) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + narrowHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let initialSlot = panel.webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected initial local inline slot") + return + } + + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: initialSlot.bounds.width, height: 72) + ) + inspectorView.autoresizingMask = [.width] + initialSlot.addSubview(inspectorView) + panel.webView.frame = NSRect( + x: 0, + y: inspectorView.frame.maxY, + width: initialSlot.bounds.width, + height: initialSlot.bounds.height - inspectorView.frame.height + ) + initialSlot.layoutSubtreeIfNeeded() + + let replacementHosting = NSHostingView(rootView: representable) + replacementHosting.frame = contentView.bounds + replacementHosting.autoresizingMask = [.width, .height] + contentView.addSubview(replacementHosting, positioned: .above, relativeTo: narrowHosting) + contentView.layoutSubtreeIfNeeded() + replacementHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + replacementHosting.rootView = representable + contentView.layoutSubtreeIfNeeded() + replacementHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + narrowHosting.removeFromSuperview() + contentView.layoutSubtreeIfNeeded() + replacementHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let replacementHost = findHostContainerView(in: replacementHosting), + let replacementSlot = findWindowBrowserSlotView(in: replacementHost) else { + XCTFail("Expected replacement local inline host") + return + } + + XCTAssertTrue( + panel.webView.superview === replacementSlot, + "A visible replacement local host should take over the hosted page" + ) + XCTAssertTrue( + inspectorView.superview === replacementSlot, + "A visible replacement local host should move the DevTools companion views with the page" + ) + XCTAssertEqual(inspectorView.frame.minX, 0, accuracy: 0.5) + XCTAssertEqual(inspectorView.frame.minY, 0, accuracy: 0.5) + XCTAssertEqual(inspectorView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) + XCTAssertEqual(inspectorView.frame.height, 72, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.minX, 0, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.minY, 72, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.height, replacementSlot.bounds.height - 72, accuracy: 0.5) } } @@ -625,6 +3262,25 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { ) } + func testArrowNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 126 + ), + -1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 125 + ), + 1 + ) + } + func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() { XCTAssertNil( browserOmnibarSelectionDeltaForCommandNavigation( @@ -678,24 +3334,756 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { 1 ) } -} -final class SidebarCommandHintPolicyTests: XCTestCase { - func testCommandHintRequiresCommandOnlyModifier() { - XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command])) - XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [])) - XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command, .shift])) - XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command, .option])) - XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command, .control])) + func testCommandNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.control, .capsLock], + chars: "n" + ), + 1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .capsLock], + chars: "p" + ), + -1 + ) } - func testCommandHintUsesIntentionalHoldDelay() { - XCTAssertGreaterThanOrEqual(SidebarCommandHintPolicy.intentionalHoldDelay, 0.25) + func testSubmitOnReturnIgnoresCapsLockModifier() { + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock])) + XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock])) + } +} + +final class BrowserReturnKeyDownRoutingTests: XCTestCase { + func testRoutesForReturnWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [] + ) + ) + } + + func testRoutesForKeypadEnterWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 76, + firstResponderIsBrowser: true, + flags: [] + ) + ) + } + + func testDoesNotRouteForNonEnterKey() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 13, + firstResponderIsBrowser: true, + flags: [] + ) + ) + } + + func testDoesNotRouteWhenFirstResponderIsNotBrowser() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: false, + flags: [] + ) + ) + } + + func testRoutesForShiftReturnWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.shift] + ) + ) + } + + func testDoesNotRouteForCommandShiftReturnWhenBrowserFirstResponder() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.command, .shift] + ) + ) + } + + func testDoesNotRouteForCommandReturnWhenBrowserFirstResponder() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.command] + ) + ) + } + + func testDoesNotRouteForOptionReturnWhenBrowserFirstResponder() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.option] + ) + ) + } + + func testDoesNotRouteForControlReturnWhenBrowserFirstResponder() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.control] + ) + ) + } +} + +final class FullScreenShortcutTests: XCTestCase { + func testMatchesCommandControlF() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "f", + keyCode: 3 + ) + ) + } + + func testMatchesCommandControlFFromKeyCodeWhenCharsAreUnavailable() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testDoesNotFallbackToANSIWhenLayoutTranslationReturnsNonFCharacter() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, _ in "u" } + ) + ) + } + + func testMatchesCommandControlFWhenCommandAwareLayoutTranslationProvidesF() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, modifierFlags in + modifierFlags.contains(.command) ? "f" : "u" + } + ) + ) + } + + func testMatchesCommandControlFWhenCharsAreControlSequence() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "\u{06}", + keyCode: 3, + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testRejectsPhysicalFWhenCharacterRepresentsDifferentLayoutKey() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "u", + keyCode: 3 + ) + ) + } + + func testIgnoresCapsLockForCommandControlF() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control, .capsLock], + chars: "f", + keyCode: 3 + ) + ) + } + + func testRejectsWhenControlIsMissing() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command], + chars: "f", + keyCode: 3 + ) + ) + } + + func testRejectsAdditionalModifiers() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control, .shift], + chars: "f", + keyCode: 3 + ) + ) + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control, .option], + chars: "f", + keyCode: 3 + ) + ) + } + + func testRejectsWhenCommandIsMissing() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.control], + chars: "f", + keyCode: 3 + ) + ) + } + + func testRejectsNonFKey() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "r", + keyCode: 15 + ) + ) + } +} + +final class BrowserZoomShortcutActionTests: XCTestCase { + func testZoomInSupportsEqualsAndPlusVariants() { + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "=", keyCode: 24), + .zoomIn + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 24), + .zoomIn + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24), + .zoomIn + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30), + .zoomIn + ) + } + + func testZoomOutSupportsMinusAndUnderscoreVariants() { + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "-", keyCode: 27), + .zoomOut + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command, .shift], chars: "_", keyCode: 27), + .zoomOut + ) + } + + func testZoomInSupportsShiftedLiteralFromDifferentPhysicalKey() { + XCTAssertEqual( + browserZoomShortcutAction( + flags: [.command, .shift], + chars: ";", + keyCode: 41, + literalChars: "+" + ), + .zoomIn + ) + + XCTAssertNil( + browserZoomShortcutAction( + flags: [.command, .shift], + chars: ";", + keyCode: 41 + ) + ) + } + + func testZoomRequiresCommandWithoutOptionOrControl() { + XCTAssertNil(browserZoomShortcutAction(flags: [], chars: "=", keyCode: 24)) + XCTAssertNil(browserZoomShortcutAction(flags: [.command, .option], chars: "=", keyCode: 24)) + XCTAssertNil(browserZoomShortcutAction(flags: [.command, .control], chars: "-", keyCode: 27)) + } + + func testResetSupportsCommandZero() { + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "0", keyCode: 29), + .reset + ) + } +} + +final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { + func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() { + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "=", + keyCode: 24 + ) + ) + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "-", + keyCode: 27 + ) + ) + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "0", + keyCode: 29 + ) + ) + } + + func testDoesNotRouteWhenFirstResponderIsNotGhostty() { + XCTAssertFalse( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: false, + flags: [.command], + chars: "=", + keyCode: 24 + ) + ) + } + + func testDoesNotRouteForNonZoomShortcuts() { + XCTAssertFalse( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "n", + keyCode: 45 + ) + ) + } + + func testRoutesForShiftedLiteralZoomShortcut() { + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command, .shift], + chars: ";", + keyCode: 41, + literalChars: "+" + ) + ) + } +} + +final class GhosttyResponderResolutionTests: XCTestCase { + private final class FocusProbeView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + func testResolvesGhosttyViewFromDescendantResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + ghosttyView.addSubview(descendant) + + XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView) + } + + func testResolvesGhosttyViewFromGhosttyResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView) + } + + func testReturnsNilForUnrelatedResponder() { + let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + XCTAssertNil(cmuxOwningGhosttyView(for: view)) + } +} + +final class CommandPaletteKeyboardNavigationTests: XCTestCase { + func testArrowKeysMoveSelectionWithoutModifiers() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 125 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 126 + ), + -1 + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.shift], + chars: "", + keyCode: 125 + ) + ) + } + + func testControlLetterNavigationSupportsPrintableAndControlChars() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "n", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0e}", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "p", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{10}", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "j", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0a}", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "k", + keyCode: 40 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0b}", + keyCode: 40 + ), + -1 + ) + } + + func testIgnoresUnsupportedModifiersAndKeys() { + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.command], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control, .shift], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "x", + keyCode: 7 + ) + ) + } +} + +final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { + func testDoesNotConsumeWhenPaletteIsNotVisible() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: false, + normalizedFlags: [.command], + chars: "n", + keyCode: 45 + ) + ) + } + + func testConsumesAppCommandShortcutsWhenPaletteIsVisible() { + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "t", + keyCode: 17 + ) + ) + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command, .shift], + chars: ",", + keyCode: 43 + ) + ) + } + + func testAllowsClipboardAndUndoShortcutsForPaletteTextEditing() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "v", + keyCode: 9 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "z", + keyCode: 6 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command, .shift], + chars: "z", + keyCode: 6 + ) + ) + } + + func testAllowsArrowAndDeleteEditingCommandsForPaletteTextEditing() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "", + keyCode: 123 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "", + keyCode: 51 + ) + ) + } + + func testConsumesEscapeWhenPaletteIsVisible() { + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [], + chars: "", + keyCode: 53 + ) + ) + } +} + +final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase { + func testRestoresBrowserAddressBarWhenPaletteOpenedFromFocusedAddressBar() { + let panelId = UUID() + XCTAssertTrue( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: true, + focusedBrowserAddressBarPanelId: panelId, + focusedPanelId: panelId + ) + ) + } + + func testDoesNotRestoreBrowserAddressBarWhenFocusedPanelIsNotBrowser() { + let panelId = UUID() + XCTAssertFalse( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: false, + focusedBrowserAddressBarPanelId: panelId, + focusedPanelId: panelId + ) + ) + } + + func testDoesNotRestoreBrowserAddressBarWhenAnotherPanelHadAddressBarFocus() { + XCTAssertFalse( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: true, + focusedBrowserAddressBarPanelId: UUID(), + focusedPanelId: UUID() + ) + ) + } +} + +final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { + private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)" + + private func makeDefaults() -> UserDefaults { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + func testDefaultsToSelectAllWhenUnset() { + let defaults = makeDefaults() + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsFalseWhenStoredFalse() { + let defaults = makeDefaults() + defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsTrueWhenStoredTrue() { + let defaults = makeDefaults() + defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } +} + +final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { + func testFirstEntryPinsToTopAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 0, + resultCount: 20 + ) + XCTAssertEqual(anchor, UnitPoint.top) + } + + func testLastEntryPinsToBottomAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 19, + resultCount: 20 + ) + XCTAssertEqual(anchor, UnitPoint.bottom) + } + + func testMiddleEntryUsesNilAnchorForMinimalScroll() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 6, + resultCount: 20 + ) + XCTAssertNil(anchor) + } + + func testEmptyResultsProduceNoAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 0, + resultCount: 0 + ) + XCTAssertNil(anchor) + } +} + +final class ShortcutHintModifierPolicyTests: XCTestCase { + func testShortcutHintRequiresEnabledCommandOnlyModifier() { + withDefaultsSuite { defaults in + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .shift], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .option], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .option], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .control], defaults: defaults)) + } + } + + func testCommandHintCanBeDisabledInSettings() { + withDefaultsSuite { defaults in + defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + } + } + + func testCommandHintDefaultsToEnabledWhenSettingMissing() { + withDefaultsSuite { defaults in + defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + } + } + + func testShortcutHintUsesIntentionalHoldDelay() { + XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001) } func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() { XCTAssertTrue( - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: 42, @@ -704,7 +4092,7 @@ final class SidebarCommandHintPolicyTests: XCTestCase { ) XCTAssertFalse( - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: 7, @@ -713,7 +4101,7 @@ final class SidebarCommandHintPolicyTests: XCTestCase { ) XCTAssertFalse( - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: false, eventWindowNumber: 42, @@ -722,26 +4110,66 @@ final class SidebarCommandHintPolicyTests: XCTestCase { ) } - func testWindowScopedCommandHintsUseKeyWindowWhenNoEventWindowIsAvailable() { - XCTAssertTrue( - SidebarCommandHintPolicy.shouldShowHints( - for: [.command], - hostWindowNumber: 42, - hostWindowIsKey: true, - eventWindowNumber: nil, - keyWindowNumber: 42 - ) - ) + func testWindowScopedShortcutHintsUseKeyWindowWhenNoEventWindowIsAvailable() { + withDefaultsSuite { defaults in + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertFalse( - SidebarCommandHintPolicy.shouldShowHints( - for: [.command], - hostWindowNumber: 42, - hostWindowIsKey: true, - eventWindowNumber: nil, - keyWindowNumber: 7 + XCTAssertTrue( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) ) - ) + + XCTAssertFalse( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 7, + defaults: defaults + ) + ) + + XCTAssertTrue( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) + ) + + XCTAssertFalse( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.control], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) + ) + } + } + + private func withDefaultsSuite(_ body: (UserDefaults) -> Void) { + let suiteName = "ShortcutHintModifierPolicyTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create defaults suite") + return + } + + defaults.removePersistentDomain(forName: suiteName) + body(defaults) + defaults.removePersistentDomain(forName: suiteName) } } @@ -761,6 +4189,81 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintX, 0.0) XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintY, 0.0) XCTAssertFalse(ShortcutHintDebugSettings.defaultAlwaysShowHints) + XCTAssertTrue(ShortcutHintDebugSettings.defaultShowHintsOnCommandHold) + } + + func testShowHintsOnCommandHoldSettingRespectsStoredValue() { + let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create defaults suite") + return + } + + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) + + defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + XCTAssertFalse(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) + + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) + } + + func testResetVisibilityDefaultsRestoresAlwaysShowAndCommandHoldFlags() { + let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create defaults suite") + return + } + + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) + defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + ShortcutHintDebugSettings.resetVisibilityDefaults(defaults: defaults) + + XCTAssertEqual( + defaults.object(forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) as? Bool, + ShortcutHintDebugSettings.defaultAlwaysShowHints + ) + XCTAssertEqual( + defaults.object(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) as? Bool, + ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + ) + } +} + +final class DevBuildBannerDebugSettingsTests: XCTestCase { + func testShowSidebarBannerDefaultsToVisible() { + let suiteName = "DevBuildBannerDebugSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + } + + func testShowSidebarBannerRespectsStoredValue() { + let suiteName = "DevBuildBannerDebugSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertFalse(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + + defaults.set(true, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) } } @@ -878,6 +4381,224 @@ final class WorkspacePlacementSettingsTests: XCTestCase { } } +@MainActor +final class WorkspaceCreationPlacementTests: XCTestCase { + func testAddWorkspaceDefaultPlacementMatchesCurrentSetting() { + let currentPlacement = WorkspacePlacementSettings.current() + + let defaultManager = makeManagerWithThreeWorkspaces() + let defaultBaselineOrder = defaultManager.tabs.map(\.id) + let defaultInserted = defaultManager.addWorkspace() + guard let defaultInsertedIndex = defaultManager.tabs.firstIndex(where: { $0.id == defaultInserted.id }) else { + XCTFail("Expected inserted workspace in tab list") + return + } + XCTAssertEqual(defaultManager.tabs.map(\.id).filter { $0 != defaultInserted.id }, defaultBaselineOrder) + + let explicitManager = makeManagerWithThreeWorkspaces() + let explicitBaselineOrder = explicitManager.tabs.map(\.id) + let explicitInserted = explicitManager.addWorkspace(placementOverride: currentPlacement) + guard let explicitInsertedIndex = explicitManager.tabs.firstIndex(where: { $0.id == explicitInserted.id }) else { + XCTFail("Expected inserted workspace in tab list") + return + } + XCTAssertEqual(explicitManager.tabs.map(\.id).filter { $0 != explicitInserted.id }, explicitBaselineOrder) + XCTAssertEqual(defaultInsertedIndex, explicitInsertedIndex) + } + + func testAddWorkspaceEndOverrideAlwaysAppends() { + let manager = makeManagerWithThreeWorkspaces() + let baselineCount = manager.tabs.count + guard baselineCount >= 3 else { + XCTFail("Expected at least three workspaces for placement regression test") + return + } + + let inserted = manager.addWorkspace(placementOverride: .end) + guard let insertedIndex = manager.tabs.firstIndex(where: { $0.id == inserted.id }) else { + XCTFail("Expected inserted workspace in tab list") + return + } + + XCTAssertEqual(insertedIndex, baselineCount) + } + + private func makeManagerWithThreeWorkspaces() -> TabManager { + let manager = TabManager() + _ = manager.addWorkspace() + _ = manager.addWorkspace() + if let first = manager.tabs.first { + manager.selectWorkspace(first) + } + return manager + } +} + +final class WorkspaceTabColorSettingsTests: XCTestCase { + func testNormalizedHexAcceptsAndNormalizesValidInput() { + XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex("#abc123"), "#ABC123") + XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex(" aBcDeF "), "#ABCDEF") + XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#1234")) + XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234")) + } + + func testBuiltInPaletteMatchesOriginalPRPalette() { + let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults) + XCTAssertEqual(palette.count, 16) + XCTAssertEqual(palette.first?.name, "Red") + XCTAssertEqual(palette.first?.hex, "#C0392B") + XCTAssertEqual(palette.last?.name, "Charcoal") + XCTAssertFalse(palette.contains(where: { $0.name == "Gold" })) + } + + func testDefaultOverrideRoundTripFallsBackWhenResetToBase() { + let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = WorkspaceTabColorSettings.defaultPalette[0] + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + "#00AA33" + ) + + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + } + + func testAddCustomColorPersistsAndDeduplicatesByMostRecent() { + let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor(" #00aa33 ", defaults: defaults), + "#00AA33" + ) + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor("#112233", defaults: defaults), + "#112233" + ) + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor("#00AA33", defaults: defaults), + "#00AA33" + ) + XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults)) + + XCTAssertEqual( + WorkspaceTabColorSettings.customColors(defaults: defaults), + ["#00AA33", "#112233"] + ) + } + + func testPaletteIncludesCustomEntriesAndResetClearsAll() { + let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = WorkspaceTabColorSettings.defaultPalette[0] + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults) + _ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults) + + let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1) + XCTAssertEqual(paletteBeforeReset[0].hex, "#334455") + XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1") + XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899") + + WorkspaceTabColorSettings.reset(defaults: defaults) + + XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), []) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + } + + func testDisplayColorLightModeKeepsOriginalHex() { + let originalHex = "#1A5276" + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .light + ) + + XCTAssertEqual(rendered?.hexString(), originalHex) + } + + func testDisplayColorDarkModeBrightensColor() { + let originalHex = "#1A5276" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .dark + ) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertNotEqual(rendered.hexString(), originalHex) + XCTAssertGreaterThan(rendered.luminance, base.luminance) + } + + func testDisplayColorDarkModeKeepsGrayscaleNeutral() { + let originalHex = "#808080" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .dark + ), + let renderedSRGB = rendered.usingColorSpace(.sRGB) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertGreaterThan(rendered.luminance, base.luminance) + XCTAssertLessThan(abs(renderedSRGB.redComponent - renderedSRGB.greenComponent), 0.003) + XCTAssertLessThan(abs(renderedSRGB.greenComponent - renderedSRGB.blueComponent), 0.003) + } + + func testDisplayColorForceBrightensInLightMode() { + let originalHex = "#1A5276" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .light, + forceBright: true + ) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertNotEqual(rendered.hexString(), originalHex) + XCTAssertGreaterThan(rendered.luminance, base.luminance) + } +} + final class WorkspaceAutoReorderSettingsTests: XCTestCase { func testDefaultIsEnabled() { let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)" @@ -943,6 +4664,44 @@ final class SidebarBranchLayoutSettingsTests: XCTestCase { } } +final class SidebarActiveTabIndicatorSettingsTests: XCTestCase { + func testDefaultStyleWhenUnset() { + let suiteName = "SidebarActiveTabIndicatorSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual( + SidebarActiveTabIndicatorSettings.current(defaults: defaults), + SidebarActiveTabIndicatorSettings.defaultStyle + ) + } + + func testStoredStyleParsesAndInvalidFallsBack() { + let suiteName = "SidebarActiveTabIndicatorSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(SidebarActiveTabIndicatorStyle.leftRail.rawValue, forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) + + defaults.set("rail", forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) + + defaults.set("not-a-style", forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual( + SidebarActiveTabIndicatorSettings.current(defaults: defaults), + SidebarActiveTabIndicatorSettings.defaultStyle + ) + } +} + final class AppearanceSettingsTests: XCTestCase { func testResolvedModeDefaultsToSystemWhenUnset() { let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" @@ -1057,6 +4816,58 @@ final class WorkspaceReorderTests: XCTestCase { } } +@MainActor +final class WorkspaceNotificationReorderTests: XCTestCase { + func testNotificationAutoReorderDoesNotMovePinnedWorkspace() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let notificationStore = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let defaults = UserDefaults.standard + let originalAutoReorderSetting = defaults.object(forKey: WorkspaceAutoReorderSettings.key) + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + notificationStore.replaceNotificationsForTesting([]) + notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = notificationStore + defaults.set(true, forKey: WorkspaceAutoReorderSettings.key) + AppFocusState.overrideIsFocused = false + + defer { + notificationStore.replaceNotificationsForTesting([]) + notificationStore.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalAutoReorderSetting { + defaults.set(originalAutoReorderSetting, forKey: WorkspaceAutoReorderSettings.key) + } else { + defaults.removeObject(forKey: WorkspaceAutoReorderSettings.key) + } + } + + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + let expectedOrder = [firstPinned.id, secondPinned.id, unpinned.id] + + notificationStore.addNotification( + tabId: secondPinned.id, + surfaceId: nil, + title: "Build finished", + subtitle: "", + body: "Pinned workspaces should stay put" + ) + + XCTAssertEqual(manager.tabs.map(\.id), expectedOrder) + } +} + @MainActor final class TabManagerChildExitCloseTests: XCTestCase { func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { @@ -1129,6 +4940,64 @@ final class TabManagerChildExitCloseTests: XCTestCase { } } +@MainActor +final class WorkspaceTeardownTests: XCTestCase { + func testTeardownAllPanelsClearsPanelMetadataCaches() { + let workspace = Workspace() + guard let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + workspace.setPanelCustomTitle(panelId: initialPanelId, title: "Initial custom title") + workspace.setPanelPinned(panelId: initialPanelId, pinned: true) + + guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { + XCTFail("Expected split panel to be created") + return + } + + workspace.setPanelCustomTitle(panelId: splitPanel.id, title: "Split custom title") + workspace.setPanelPinned(panelId: splitPanel.id, pinned: true) + workspace.markPanelUnread(initialPanelId) + + XCTAssertFalse(workspace.panels.isEmpty) + XCTAssertFalse(workspace.panelTitles.isEmpty) + XCTAssertFalse(workspace.panelCustomTitles.isEmpty) + XCTAssertFalse(workspace.pinnedPanelIds.isEmpty) + XCTAssertFalse(workspace.manualUnreadPanelIds.isEmpty) + + workspace.teardownAllPanels() + + XCTAssertTrue(workspace.panels.isEmpty) + XCTAssertTrue(workspace.panelTitles.isEmpty) + XCTAssertTrue(workspace.panelCustomTitles.isEmpty) + XCTAssertTrue(workspace.pinnedPanelIds.isEmpty) + XCTAssertTrue(workspace.manualUnreadPanelIds.isEmpty) + } +} + +@MainActor +final class TabManagerWorkspaceOwnershipTests: XCTestCase { + func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() { + let manager = TabManager() + _ = manager.addWorkspace() + let initialTabIds = manager.tabs.map(\.id) + let initialSelectedTabId = manager.selectedTabId + + let externalWorkspace = Workspace(title: "External workspace") + let externalPanelCountBefore = externalWorkspace.panels.count + let externalPanelTitlesBefore = externalWorkspace.panelTitles + + manager.closeWorkspace(externalWorkspace) + + XCTAssertEqual(manager.tabs.map(\.id), initialTabIds) + XCTAssertEqual(manager.selectedTabId, initialSelectedTabId) + XCTAssertEqual(externalWorkspace.panels.count, externalPanelCountBefore) + XCTAssertEqual(externalWorkspace.panelTitles, externalPanelTitlesBefore) + } +} + @MainActor final class TabManagerPendingUnfocusPolicyTests: XCTestCase { func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() { @@ -1211,6 +5080,434 @@ final class TabManagerSurfaceCreationTests: XCTestCase { ) XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused") } + + func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() { + let manager = TabManager() + guard let initialWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial selected workspace") + return + } + guard let url = URL(string: "https://example.com/pull/123") else { + XCTFail("Expected test URL to be valid") + return + } + + let targetWorkspace = manager.addWorkspace(select: false) + manager.selectWorkspace(initialWorkspace) + let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count + let initialPanelCount = targetWorkspace.panels.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: targetWorkspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created in target workspace") + return + } + + XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected") + XCTAssertEqual( + targetWorkspace.bonsplitController.allPaneIds.count, + initialPaneCount + 1, + "Expected split-right browser open to create a new pane" + ) + XCTAssertEqual( + targetWorkspace.panels.count, + initialPanelCount + 1, + "Expected browser panel count to increase by one" + ) + XCTAssertEqual( + targetWorkspace.focusedPanelId, + browserPanelId, + "Expected created browser panel to be focused in target workspace" + ) + XCTAssertTrue( + targetWorkspace.panels[browserPanelId] is BrowserPanel, + "Expected created panel to be a browser panel" + ) + } + + func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil, + let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id), + let url = URL(string: "https://example.com/pull/456") else { + XCTFail("Expected split setup to succeed") + return + } + + let initialPaneCount = workspace.bonsplitController.allPaneIds.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: workspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created") + return + } + + XCTAssertEqual( + workspace.bonsplitController.allPaneIds.count, + initialPaneCount, + "Expected split-right browser open to reuse existing panes" + ) + XCTAssertEqual( + workspace.paneId(forPanelId: browserPanelId), + topRightPaneId, + "Expected browser to open in the top-right pane when multiple splits already exist" + ) + + let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId) + guard let lastSurfaceId = targetPaneTabs.last?.id else { + XCTFail("Expected top-right pane to contain tabs") + return + } + XCTAssertEqual( + workspace.panelIdFromSurfaceId(lastSurfaceId), + browserPanelId, + "Expected browser surface to be appended at end in the reused top-right pane" + ) + } +} + +@MainActor +final class TabManagerEqualizeSplitsTests: XCTestCase { + func testEqualizeSplitsSetsEverySplitDividerToHalf() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + workspace.newTerminalSplit(from: rightPanel.id, orientation: .vertical) != nil else { + XCTFail("Expected nested split setup to succeed") + return + } + + let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) + XCTAssertGreaterThanOrEqual(initialSplits.count, 2, "Expected at least two split nodes in nested layout") + + for (index, split) in initialSplits.enumerated() { + guard let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected split ID to be a UUID") + return + } + let targetPosition: CGFloat = index.isMultiple(of: 2) ? 0.2 : 0.8 + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(targetPosition, forSplit: splitId), + "Expected to seed divider position for split \(splitId)" + ) + } + + XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id), "Expected equalize splits command to succeed") + + let equalizedSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) + XCTAssertEqual(equalizedSplits.count, initialSplits.count) + for split in equalizedSplits { + XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1) + } + } + + private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { + switch node { + case .pane: + return [] + case .split(let split): + return [split] + splitNodes(in: split.first) + splitNodes(in: split.second) + } + } +} + +@MainActor +final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 220), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + } + + private func makeMouseEvent( + type: NSEvent.EventType, + location: NSPoint, + window: NSWindow + ) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? { + var stack: [NSView] = [hostedView] + while let current = stack.popLast() { + if let surfaceView = current as? GhosttyNSView { + return surfaceView + } + stack.append(contentsOf: current.subviews) + } + return nil + } + + func testTerminalFirstResponderConvergesSplitActiveStateWhenSelectionAlreadyMatches() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId, + let leftPanel = workspace.terminalPanel(for: leftPanelId), + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + XCTAssertEqual( + workspace.focusedPanelId, + rightPanel.id, + "Expected the new split panel to be selected before simulating stale focus state" + ) + + // Simulate the split-pane failure mode: Bonsplit already points at the right panel, + // but the active terminal state is still stale on the left panel. + leftPanel.surface.setFocus(true) + leftPanel.hostedView.setActive(true) + rightPanel.surface.setFocus(false) + rightPanel.hostedView.setActive(false) + + workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder) + + XCTAssertFalse( + leftPanel.hostedView.debugRenderStats().isActive, + "Expected stale left-pane active state to be cleared" + ) + XCTAssertTrue( + rightPanel.hostedView.debugRenderStats().isActive, + "Expected terminal-first-responder recovery to reactivate the selected split pane" + ) + } + + func testTerminalClickRecoversSplitActiveStateWhenFocusCallbackIsSuppressed() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId, + let leftPanel = workspace.terminalPanel(for: leftPanelId), + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + let window = makeWindow() + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220) + rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220) + contentView.addSubview(leftPanel.hostedView) + contentView.addSubview(rightPanel.hostedView) + + leftPanel.hostedView.setVisibleInUI(true) + rightPanel.hostedView.setVisibleInUI(true) + leftPanel.hostedView.setFocusHandler { + workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder) + } + rightPanel.hostedView.setFocusHandler { + workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder) + } + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertEqual( + workspace.focusedPanelId, + rightPanel.id, + "Expected the clicked split pane to already be selected before simulating stale focus state" + ) + + // Simulate the ghost-terminal race: the right pane is selected in Bonsplit, but stale + // active state remains on the left and the right pane's AppKit focus callback never fires + // after split reparent/layout churn. + leftPanel.surface.setFocus(true) + leftPanel.hostedView.setActive(true) + rightPanel.surface.setFocus(false) + rightPanel.hostedView.setActive(false) + rightPanel.hostedView.suppressReparentFocus() + + guard let rightSurfaceView = surfaceView(in: rightPanel.hostedView) else { + XCTFail("Expected right terminal surface view") + return + } + + let pointInWindow = rightSurfaceView.convert(NSPoint(x: 24, y: 24), to: nil) + let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) + rightSurfaceView.mouseDown(with: event) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + leftPanel.hostedView.debugRenderStats().isActive, + "Expected clicking the selected split pane to clear stale sibling active state even when AppKit focus callbacks are suppressed" + ) + XCTAssertTrue( + rightPanel.hostedView.debugRenderStats().isActive, + "Expected clicking the selected split pane to reactivate terminal input when focus callbacks are suppressed" + ) + XCTAssertTrue( + rightPanel.hostedView.isSurfaceViewFirstResponder(), + "Expected the clicked split pane to become first responder" + ) + } +} + +@MainActor +final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { + func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else { + XCTFail("Expected workspace split setup to succeed") + return + } + + // Programmatic split focuses the new right panel by default. + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftPanelId, + "Expected inheritance to use the selected terminal in the target pane" + ) + } + + func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected workspace browser setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId) + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected inheritance to fall back to a terminal in the pane when browser is selected" + ) + } + + func testPreferredTerminalPanelWinsWhenProvided() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a terminal panel") + return + } + + let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId) + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected inheritance to prefer last focused terminal when browser is focused in another pane" + ) + } +} + +@MainActor +final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { + func testUsesFocusedTerminalWhenTerminalIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused terminal") + return + } + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testFallsBackToTerminalWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected selected workspace setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected new workspace inheritance source to resolve to the pane terminal when browser is focused" + ) + } + + func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected workspace inheritance source to use last focused terminal across panes" + ) + } } @MainActor @@ -1384,6 +5681,269 @@ final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { @MainActor final class WorkspacePanelGitBranchTests: XCTestCase { + private func drainMainQueue() { + let expectation = expectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus browser split to preserve pre-split focus" + ) + } + + func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let terminalSplitPanel = workspace.newTerminalSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected terminal split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus terminal split to preserve pre-split focus" + ) + } + + func testDetachLastSurfaceLeavesWorkspaceTemporarilyEmptyForMoveFlow() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: panelId) else { + XCTFail("Expected initial panel and pane") + return + } + + XCTAssertEqual(workspace.panels.count, 1) +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif + + guard let detached = workspace.detachSurface(panelId: panelId) else { + XCTFail("Expected detach of last surface to succeed") + return + } + + XCTAssertEqual(detached.panelId, panelId) + XCTAssertTrue( + workspace.panels.isEmpty, + "Detaching the last surface should not auto-create a replacement panel" + ) + XCTAssertNil(workspace.surfaceIdFromPanelId(panelId)) + XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0) + + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching during cross-workspace moves should not schedule delayed source focus reconciliation" + ) +#endif + + let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false) + XCTAssertEqual(restoredPanelId, panelId) + XCTAssertEqual(workspace.panels.count, 1) + } + + func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() { + let workspace = Workspace() + guard let originalPanelId = workspace.focusedPanelId, + let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else { + XCTFail("Expected two panels before detach") + return + } + + drainMainQueue() + drainMainQueue() +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif + + guard let detached = workspace.detachSurface(panelId: movedPanel.id) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.panelId, movedPanel.id) + XCTAssertEqual(workspace.panels.count, 1, "Expected source workspace to retain only the surviving panel") + XCTAssertNotNil(workspace.panels[originalPanelId], "Expected the original panel to remain after detach") + + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching into another workspace should not enqueue delayed source focus reconciliation" + ) +#endif + } + + func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() { + let source = Workspace() + guard let panelId = source.focusedPanelId else { + XCTFail("Expected source focused panel") + return + } + + XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title")) + + guard let detached = source.detachSurface(panelId: panelId) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.cachedTitle, "detached-runtime-title") + XCTAssertNil(detached.customTitle) + XCTAssertEqual( + detached.title, + "detached-runtime-title", + "Detached transfer should carry the cached non-custom title" + ) + + let destination = Workspace() + guard let destinationPane = destination.bonsplitController.allPaneIds.first else { + XCTFail("Expected destination pane") + return + } + + let attachedPanelId = destination.attachDetachedSurface( + detached, + inPane: destinationPane, + focus: false + ) + XCTAssertEqual(attachedPanelId, panelId) + XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title") + + guard let attachedTabId = destination.surfaceIdFromPanelId(panelId), + let attachedTab = destination.bonsplitController.tab(attachedTabId) else { + XCTFail("Expected attached tab mapping") + return + } + XCTAssertEqual(attachedTab.title, "detached-runtime-title") + XCTAssertFalse(attachedTab.hasCustomTitle) + } + + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected focused pane for initial panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id), + let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id), + let splitTab = workspace.bonsplitController + .tabs(inPane: splitPaneId) + .first(where: { $0.id == splitTabId }) else { + XCTFail("Expected split pane/tab mapping") + return + } + + // Simulate one delayed stale split-selection callback from bonsplit. + DispatchQueue.main.async { + workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId) + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus split to reassert the pre-split focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.focusedPaneId, + originalPaneId, + "Expected focused pane to converge back to the pre-split pane" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to converge back to the pre-split focused panel" + ) + } + + func testBrowserSplitWithFocusFalseAllowsSubsequentExplicitFocusOnSplitPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + + workspace.focusPanel(browserSplitPanel.id) + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual( + workspace.focusedPanelId, + browserSplitPanel.id, + "Expected explicit focus intent to keep the split panel focused" + ) + } + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId else { @@ -1459,6 +6019,58 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(branches.map(\.isDirty), [true, false, false]) } + func testSidebarDerivedCollectionsMatchWhenUsingPrecomputedPanelOrder() { + let workspace = Workspace() + guard let leftFirstPanelId = workspace.focusedPanelId, + let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId), + let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id), + let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false), + let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else { + XCTFail("Expected panes and panels for precomputed ordering test") + return + } + + workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false) + workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: true) + workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "release/right", isDirty: false) + + workspace.updatePanelDirectory(panelId: leftFirstPanelId, directory: "/repo/left/root") + workspace.updatePanelDirectory(panelId: leftSecondPanel.id, directory: "/repo/left/feature") + workspace.updatePanelDirectory(panelId: rightFirstPanel.id, directory: "/repo/right/root") + workspace.updatePanelDirectory(panelId: rightSecondPanel.id, directory: "/repo/right/extra") + + workspace.updatePanelPullRequest( + panelId: leftFirstPanelId, + number: 101, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/101")!, + status: .open + ) + workspace.updatePanelPullRequest( + panelId: rightFirstPanel.id, + number: 18, + label: "MR", + url: URL(string: "https://gitlab.com/manaflow/cmux/-/merge_requests/18")!, + status: .merged + ) + + let orderedPanelIds = workspace.sidebarOrderedPanelIds() + + XCTAssertEqual( + workspace.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { "\($0.branch)|\($0.isDirty)" }, + workspace.sidebarGitBranchesInDisplayOrder().map { "\($0.branch)|\($0.isDirty)" } + ) + XCTAssertEqual( + workspace.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds), + workspace.sidebarBranchDirectoryEntriesInDisplayOrder() + ) + XCTAssertEqual( + workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds), + workspace.sidebarPullRequestsInDisplayOrder() + ) + } + func testClosingPaneDropsBranchesFromClosedSide() { let workspace = Workspace() guard let leftPanelId = workspace.focusedPanelId, @@ -1591,6 +6203,149 @@ final class SidebarBranchOrderingTests: XCTestCase { [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")] ) } + + func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() { + let first = UUID() + let second = UUID() + let third = UUID() + let fourth = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second, third, fourth], + panelPullRequests: [ + first: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .open + ), + second: pullRequestState( + number: 18, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18", + status: .open + ), + third: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .merged + ), + fourth: pullRequestState( + number: 92, + label: "PR", + url: "https://bitbucket.org/manaflow/cmux/pull-requests/92", + status: .closed + ) + ], + fallbackPullRequest: pullRequestState( + number: 1, + label: "PR", + url: "https://example.invalid/fallback/1", + status: .open + ) + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#337", "MR#18", "PR#92"] + ) + XCTAssertEqual( + pullRequests.map(\.status), + [.merged, .open, .closed] + ) + } + + func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#42", "MR#42"] + ) + } + + func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/other-repo/pull/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map(\.url.absoluteString), + [ + "https://github.com/manaflow-ai/cmux/pull/42", + "https://github.com/manaflow-ai/other-repo/pull/42" + ] + ) + } + + func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() { + let fallback = pullRequestState( + number: 11, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/11", + status: .open + ) + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [], + panelPullRequests: [:], + fallbackPullRequest: fallback + ) + + XCTAssertEqual(pullRequests, [fallback]) + } + + private func pullRequestState( + number: Int, + label: String, + url: String, + status: SidebarPullRequestStatus + ) -> SidebarPullRequestState { + SidebarPullRequestState( + number: number, + label: label, + url: URL(string: url)!, + status: status + ) + } } @MainActor @@ -1886,6 +6641,298 @@ final class FinderServicePathResolverTests: XCTestCase { } } +final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { + private func environment( + existingPaths: Set<String>, + homeDirectoryPath: String = "/Users/tester" + ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { + TerminalDirectoryOpenTarget.DetectionEnvironment( + homeDirectoryPath: homeDirectoryPath, + fileExistsAtPath: { existingPaths.contains($0) }, + isExecutableFileAtPath: { existingPaths.contains($0) } + ) + } + + func testAvailableTargetsDetectSystemApplications() { + let env = environment( + existingPaths: [ + "/Applications/Visual Studio Code.app", + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel", + "/System/Library/CoreServices/Finder.app", + "/System/Applications/Utilities/Terminal.app", + "/Applications/Zed Preview.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.vscode)) + XCTAssertTrue(availableTargets.contains(.finder)) + XCTAssertTrue(availableTargets.contains(.terminal)) + XCTAssertTrue(availableTargets.contains(.zed)) + XCTAssertFalse(availableTargets.contains(.cursor)) + } + + func testAvailableTargetsFallbackToUserApplications() { + let env = environment( + existingPaths: [ + "/Users/tester/Applications/Cursor.app", + "/Users/tester/Applications/Warp.app", + "/Users/tester/Applications/Android Studio.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.cursor)) + XCTAssertTrue(availableTargets.contains(.warp)) + XCTAssertTrue(availableTargets.contains(.androidStudio)) + XCTAssertFalse(availableTargets.contains(.vscode)) + } + + func testVSCodeRequiresCodeTunnelExecutable() { + let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"]) + XCTAssertFalse(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env)) + } + + func testITerm2DetectsLegacyBundleName() { + let env = environment(existingPaths: ["/Applications/iTerm.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env)) + } + + func testTowerDetected() { + let env = environment(existingPaths: ["/Applications/Tower.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env)) + } + + func testCommandPaletteShortcutsExcludeGenericIDEEntry() { + let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets + XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" })) + XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" })) + } +} + +final class VSCodeServeWebURLBuilderTests: XCTestCase { + func testExtractWebUIURLParsesServeWebOutput() { + let output = """ + * + * Visual Studio Code Server + * + Web UI available at http://127.0.0.1:5555?tkn=test-token + """ + + let url = VSCodeServeWebURLBuilder.extractWebUIURL(from: output) + XCTAssertEqual(url?.absoluteString, "http://127.0.0.1:5555?tkn=test-token") + } + + func testOpenFolderURLAppendsFolderQueryWhilePreservingToken() { + let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token")! + + let url = VSCodeServeWebURLBuilder.openFolderURL( + baseWebUIURL: baseURL, + directoryPath: "/Users/tester/Projects/cmux" + ) + + let components = URLComponents(url: url!, resolvingAgainstBaseURL: false) + XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "tkn" })?.value, "test-token") + XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "folder" })?.value, "/Users/tester/Projects/cmux") + } + + func testOpenFolderURLReplacesExistingFolderQuery() { + let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token&folder=/tmp/old")! + + let url = VSCodeServeWebURLBuilder.openFolderURL( + baseWebUIURL: baseURL, + directoryPath: "/Users/tester/New Folder" + ) + + let components = URLComponents(url: url!, resolvingAgainstBaseURL: false) + XCTAssertEqual( + components?.queryItems?.filter { $0.name == "folder" }.count, + 1 + ) + XCTAssertEqual( + components?.queryItems?.first(where: { $0.name == "folder" })?.value, + "/Users/tester/New Folder" + ) + } +} + +final class VSCodeCLILaunchConfigurationBuilderTests: XCTestCase { + func testLaunchConfigurationUsesCodeTunnelBinary() { + let appURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true) + let expectedExecutablePath = "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel" + + let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: appURL, + baseEnvironment: [:], + isExecutableAtPath: { $0 == expectedExecutablePath } + ) + + XCTAssertEqual(configuration?.executableURL.path, expectedExecutablePath) + XCTAssertEqual(configuration?.argumentsPrefix, []) + XCTAssertEqual(configuration?.environment["ELECTRON_RUN_AS_NODE"], "1") + } + + func testLaunchConfigurationMapsNodeEnvironmentVariables() { + let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true), + baseEnvironment: [ + "PATH": "/usr/bin:/bin", + "NODE_OPTIONS": "--max-old-space-size=4096", + "NODE_REPL_EXTERNAL_MODULE": "module-name" + ], + isExecutableAtPath: { _ in true } + ) + + XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin") + XCTAssertEqual(configuration?.environment["VSCODE_NODE_OPTIONS"], "--max-old-space-size=4096") + XCTAssertEqual(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"], "module-name") + XCTAssertNil(configuration?.environment["NODE_OPTIONS"]) + XCTAssertNil(configuration?.environment["NODE_REPL_EXTERNAL_MODULE"]) + } + + func testLaunchConfigurationClearsStaleVSCodeNodeVariablesWhenNodeVariablesAreAbsent() { + let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true), + baseEnvironment: [ + "PATH": "/usr/bin:/bin", + "VSCODE_NODE_OPTIONS": "--stale", + "VSCODE_NODE_REPL_EXTERNAL_MODULE": "stale-module" + ], + isExecutableAtPath: { _ in true } + ) + + XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin") + XCTAssertNil(configuration?.environment["VSCODE_NODE_OPTIONS"]) + XCTAssertNil(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"]) + } +} + +final class ServeWebOutputCollectorTests: XCTestCase { + func testWaitForURLReturnsFalseAfterProcessExitSignal() { + let collector = ServeWebOutputCollector() + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + collector.markProcessExited() + } + + let start = Date() + let resolved = collector.waitForURL(timeoutSeconds: 1) + let elapsed = Date().timeIntervalSince(start) + + XCTAssertFalse(resolved) + XCTAssertLessThan(elapsed, 0.5) + } + + func testWaitForURLReturnsTrueWhenURLIsCollected() { + let collector = ServeWebOutputCollector() + let urlLine = "Web UI available at http://127.0.0.1:7777?tkn=test-token\n" + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + collector.append(Data(urlLine.utf8)) + } + + XCTAssertTrue(collector.waitForURL(timeoutSeconds: 1)) + XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:7777?tkn=test-token") + } + + func testMarkProcessExitedParsesFinalURLWithoutTrailingNewline() { + let collector = ServeWebOutputCollector() + let finalChunk = "Web UI available at http://127.0.0.1:9001?tkn=final-token" + + collector.append(Data(finalChunk.utf8)) + collector.markProcessExited() + + XCTAssertTrue(collector.waitForURL(timeoutSeconds: 0.1)) + XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:9001?tkn=final-token") + } +} + +final class VSCodeServeWebControllerTests: XCTestCase { + func testStopDuringInFlightLaunchDoesNotDropNextGenerationCompletion() { + let firstLaunchStarted = expectation(description: "first launch started") + let firstCompletionCalled = expectation(description: "first generation completion called") + let secondCompletionCalled = expectation(description: "second generation completion called") + + let launchGate = DispatchSemaphore(value: 0) + let launchCallLock = NSLock() + var launchCallCount = 0 + + let controller = VSCodeServeWebController.makeForTesting { _, _ in + launchCallLock.lock() + launchCallCount += 1 + let callNumber = launchCallCount + launchCallLock.unlock() + + if callNumber == 1 { + firstLaunchStarted.fulfill() + _ = launchGate.wait(timeout: .now() + 1) + } + return nil + } + + let callbackLock = NSLock() + var firstGenerationCallbacks: [URL?] = [] + var secondGenerationCallbacks: [URL?] = [] + let vscodeAppURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true) + + controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in + callbackLock.lock() + firstGenerationCallbacks.append(url) + callbackLock.unlock() + firstCompletionCalled.fulfill() + } + + wait(for: [firstLaunchStarted], timeout: 1) + controller.stop() + + controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in + callbackLock.lock() + secondGenerationCallbacks.append(url) + callbackLock.unlock() + secondCompletionCalled.fulfill() + } + + launchGate.signal() + wait(for: [firstCompletionCalled, secondCompletionCalled], timeout: 2) + + callbackLock.lock() + let firstSnapshot = firstGenerationCallbacks + let secondSnapshot = secondGenerationCallbacks + callbackLock.unlock() + + launchCallLock.lock() + let launchCalls = launchCallCount + launchCallLock.unlock() + + XCTAssertEqual(firstSnapshot.count, 1) + if firstSnapshot.count == 1 { + XCTAssertNil(firstSnapshot[0]) + } + XCTAssertEqual(secondSnapshot.count, 1) + if secondSnapshot.count == 1 { + XCTAssertNil(secondSnapshot[0]) + } + XCTAssertEqual(launchCalls, 2) + } + + func testStopRemovesOrphanedConnectionTokenFiles() throws { + let tokenFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tokenFileURL) } + try Data("token".utf8).write(to: tokenFileURL) + XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path)) + + let controller = VSCodeServeWebController.makeForTesting { _, _ in + XCTFail("Expected no launch") + return nil + } + controller.trackConnectionTokenFileForTesting(tokenFileURL) + + controller.stop() + + XCTAssertFalse(FileManager.default.fileExists(atPath: tokenFileURL.path)) + } +} + final class BrowserSearchEngineTests: XCTestCase { func testGoogleSearchURL() throws { let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world")) @@ -2587,6 +7634,31 @@ final class OmnibarSuggestionRankingTests: XCTestCase { @MainActor final class NotificationDockBadgeTests: XCTestCase { + private final class NotificationSettingsAlertSpy: NSAlert { + private(set) var beginSheetModalCallCount = 0 + private(set) var runModalCallCount = 0 + var nextResponse: NSApplication.ModalResponse = .alertFirstButtonReturn + + override func beginSheetModal( + for sheetWindow: NSWindow, + completionHandler handler: ((NSApplication.ModalResponse) -> Void)? + ) { + beginSheetModalCallCount += 1 + handler?(nextResponse) + } + + override func runModal() -> NSApplication.ModalResponse { + runModalCallCount += 1 + return nextResponse + } + } + + override func tearDown() { + TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting() + TerminalNotificationStore.shared.replaceNotificationsForTesting([]) + super.tearDown() + } + func testDockBadgeLabelEnabledAndCounted() { XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 1, isEnabled: true), "1") XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 42, isEnabled: true), "42") @@ -2634,6 +7706,696 @@ final class NotificationDockBadgeTests: XCTestCase { defaults.set(true, forKey: NotificationBadgeSettings.dockBadgeEnabledKey) XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults)) } + + func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + + defaults.set("Ping", forKey: NotificationSoundSettings.key) + XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set("none", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationCustomFileURLExpandsTildePath() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let rawPath = "~/Library/Sounds/my-custom.wav" + defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey) + let expectedPath = (rawPath as NSString).expandingTildeInPath + XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath) + } + + func testNotificationCustomFileSelectionMustBeExplicit() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey) + + defaults.set("none", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + + defaults.set("Ping", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + } + + func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let fileManager = FileManager.default + let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + do { + try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + } catch { + XCTFail("Failed to create sounds directory: \(error)") + return + } + + let sourceURL = soundsDirectory.appendingPathComponent( + "cmux-custom-notification-sound.source-\(UUID().uuidString).wav", + isDirectory: false + ) + defer { + try? fileManager.removeItem(at: sourceURL) + } + + do { + try Data("test".utf8).write(to: sourceURL, options: .atomic) + } catch { + XCTFail("Failed to write source custom sound file: \(error)") + return + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + _ = NotificationSoundSettings.sound(defaults: defaults) + + guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else { + XCTFail("Expected staged custom sound name") + return + } + let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) + defer { + try? fileManager.removeItem(at: stagedURL) + } + + XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) + XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-")) + XCTAssertTrue(stagedName.hasSuffix(".wav")) + } + + func testNotificationCustomUnsupportedExtensionsStageAsCaf() { + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"), + "caf" + ) + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"), + "caf" + ) + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"), + "wav" + ) + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"), + "aiff" + ) + + let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3") + let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3") + let stagedA = NotificationSoundSettings.stagedCustomSoundFileName( + forSourceURL: sourceA, + destinationExtension: "caf" + ) + let stagedB = NotificationSoundSettings.stagedCustomSoundFileName( + forSourceURL: sourceB, + destinationExtension: "caf" + ) + XCTAssertNotEqual(stagedA, stagedB) + XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-")) + XCTAssertTrue(stagedA.hasSuffix(".caf")) + } + + func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let fileManager = FileManager.default + let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + do { + try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + } catch { + XCTFail("Failed to create sounds directory: \(error)") + return + } + + let sourceURL = soundsDirectory.appendingPathComponent( + "cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav", + isDirectory: false + ) + do { + try Data("test".utf8).write(to: sourceURL, options: .atomic) + } catch { + XCTFail("Failed to write source custom sound file: \(error)") + return + } + defer { + try? fileManager.removeItem(at: sourceURL) + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path) + let stagedName: String + switch prepareResult { + case .success(let name): + stagedName = name + case .failure(let issue): + XCTFail("Expected custom sound preparation success, got \(issue)") + return + } + + let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) + let metadataURL = stagedURL.appendingPathExtension("source-metadata") + defer { + try? fileManager.removeItem(at: stagedURL) + try? fileManager.removeItem(at: metadataURL) + } + + XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path)) + } + + func testNotificationCustomSoundReturnsNilWhenPreparationFails() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let invalidSourceURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false) + defer { + try? FileManager.default.removeItem(at: invalidSourceURL) + let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + .appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false) + try? FileManager.default.removeItem(at: stagedURL) + } + + do { + try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic) + } catch { + XCTFail("Failed to write invalid custom sound source: \(error)") + return + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationCustomPreparationReportsMissingFile() { + let missingPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false) + .path + + let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath) + switch result { + case .success: + XCTFail("Expected missing file failure") + case .failure(let issue): + guard case .missingFile = issue else { + XCTFail("Expected missingFile issue, got \(issue)") + return + } + } + } + + func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() { + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional) + } + + func testNotificationAuthorizationStateDeliveryCapability() { + XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery) + XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery) + XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery) + } + + func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() { + XCTAssertTrue( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .notDetermined, + isAppActive: false + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .notDetermined, + isAppActive: true + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .authorized, + isAppActive: false + ) + ) + } + + func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() { + XCTAssertTrue( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: false, + hasRequestedAutomaticAuthorization: true + ) + ) + XCTAssertTrue( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: true, + hasRequestedAutomaticAuthorization: false + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: true, + hasRequestedAutomaticAuthorization: true + ) + ) + } + + func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() { + let store = TerminalNotificationStore.shared + let alertSpy = NotificationSettingsAlertSpy() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + + var openedURL: URL? + store.configureNotificationSettingsPromptHooksForTesting( + windowProvider: { window }, + alertFactory: { alertSpy }, + scheduler: { _, block in block() }, + urlOpener: { openedURL = $0 } + ) + + store.promptToEnableNotificationsForTesting() + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + XCTAssertEqual( + openedURL?.absoluteString, + "x-apple.systempreferences:com.apple.preference.notifications" + ) + } + + func testNotificationSettingsPromptRetriesUntilWindowExists() { + let store = TerminalNotificationStore.shared + let alertSpy = NotificationSettingsAlertSpy() + alertSpy.nextResponse = .alertSecondButtonReturn + + var queuedRetryBlocks: [() -> Void] = [] + var promptWindow: NSWindow? + store.configureNotificationSettingsPromptHooksForTesting( + windowProvider: { promptWindow }, + alertFactory: { alertSpy }, + scheduler: { _, block in queuedRetryBlocks.append(block) }, + urlOpener: { _ in XCTFail("Should not open settings for Not Now response") } + ) + + store.promptToEnableNotificationsForTesting() + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + XCTAssertEqual(queuedRetryBlocks.count, 1) + + promptWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + queuedRetryBlocks.removeFirst()() + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + } + + func testNotificationIndexesTrackUnreadCountsByTabAndSurface() { + let tabA = UUID() + let tabB = UUID() + let surfaceA = UUID() + let surfaceB = UUID() + let notificationAUnread = TerminalNotification( + id: UUID(), + tabId: tabA, + surfaceId: surfaceA, + title: "A unread", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + let notificationARead = TerminalNotification( + id: UUID(), + tabId: tabA, + surfaceId: surfaceB, + title: "A read", + subtitle: "", + body: "", + createdAt: Date(), + isRead: true + ) + let notificationBUnread = TerminalNotification( + id: UUID(), + tabId: tabB, + surfaceId: nil, + title: "B unread", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([ + notificationAUnread, + notificationARead, + notificationBUnread + ]) + + XCTAssertEqual(store.unreadCount, 2) + XCTAssertEqual(store.unreadCount(forTabId: tabA), 1) + XCTAssertEqual(store.unreadCount(forTabId: tabB), 1) + XCTAssertTrue(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceA)) + XCTAssertFalse(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceB)) + XCTAssertTrue(store.hasUnreadNotification(forTabId: tabB, surfaceId: nil)) + XCTAssertEqual(store.latestNotification(forTabId: tabA)?.id, notificationAUnread.id) + XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id) + } + + func testNotificationIndexesUpdateAfterReadAndClearMutations() { + let tab = UUID() + let surfaceUnread = UUID() + let surfaceRead = UUID() + let unreadNotification = TerminalNotification( + id: UUID(), + tabId: tab, + surfaceId: surfaceUnread, + title: "Unread", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + let readNotification = TerminalNotification( + id: UUID(), + tabId: tab, + surfaceId: surfaceRead, + title: "Read", + subtitle: "", + body: "", + createdAt: Date(), + isRead: true + ) + + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([unreadNotification, readNotification]) + XCTAssertEqual(store.unreadCount(forTabId: tab), 1) + XCTAssertTrue(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread)) + + store.markRead(forTabId: tab, surfaceId: surfaceUnread) + XCTAssertEqual(store.unreadCount(forTabId: tab), 0) + XCTAssertFalse(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread)) + XCTAssertEqual(store.latestNotification(forTabId: tab)?.id, unreadNotification.id) + + store.clearNotifications(forTabId: tab) + XCTAssertEqual(store.unreadCount(forTabId: tab), 0) + XCTAssertNil(store.latestNotification(forTabId: tab)) + } +} + +@MainActor +final class TerminalNotificationDirectInteractionTests: XCTestCase { + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + return window + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + private func makeKeyEvent(characters: String, keyCode: UInt16, window: NSWindow) -> NSEvent { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) else { + fatalError("Failed to create key event") + } + return event + } + + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? { + hostedView.subviews + .compactMap { $0 as? NSScrollView } + .first? + .documentView? + .subviews + .first + } + + func testTerminalMouseDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let window = makeWindow() + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + window.orderOut(nil) + } + + guard let workspace = manager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected an initial focused terminal panel") + return + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + + guard let surfaceView = surfaceView(in: hostedView) else { + XCTFail("Expected terminal surface view") + return + } + + GhosttySurfaceScrollView.resetFlashCounts() + AppFocusState.overrideIsFocused = true + XCTAssertTrue(window.makeFirstResponder(surfaceView)) + + store.addNotification( + tabId: workspace.id, + surfaceId: terminalPanel.id, + title: "Unread", + subtitle: "", + body: "" + ) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + + AppFocusState.overrideIsFocused = true + let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil) + let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) + surfaceView.mouseDown(with: event) + let drained = expectation(description: "flash drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) + } + + func testTerminalKeyDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let window = makeWindow() + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + window.orderOut(nil) + } + + guard let workspace = manager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected an initial focused terminal panel") + return + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + + guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else { + XCTFail("Expected terminal surface view") + return + } + + GhosttySurfaceScrollView.resetFlashCounts() + AppFocusState.overrideIsFocused = true + XCTAssertTrue(window.makeFirstResponder(surfaceView)) + + store.addNotification( + tabId: workspace.id, + surfaceId: terminalPanel.id, + title: "Unread", + subtitle: "", + body: "" + ) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + + let event = makeKeyEvent(characters: "", keyCode: 122, window: window) + surfaceView.keyDown(with: event) + let drained = expectation(description: "flash drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) + } } @@ -3092,8 +8854,64 @@ final class WindowBrowserHostViewTests: XCTestCase { } } + private final class PrimaryPageProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class WKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class EdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x <= 12 ? nil : self + } + } + + private final class TrailingEdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x >= bounds.maxX - 12 ? nil : self + } + } + private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + private func isInspectorOwnedHit(_ hit: NSView?, inspectorView: NSView, pageView: NSView) -> Bool { + guard let hit else { return false } + if hit === pageView || hit.isDescendant(of: pageView) { + return false + } + if hit === inspectorView || hit.isDescendant(of: inspectorView) { + return true + } + return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit)) + } + func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), @@ -3122,12 +8940,18 @@ final class WindowBrowserHostViewTests: XCTestCase { splitView.adjustSubviews() contentView.layoutSubtreeIfNeeded() - let host = WindowBrowserHostView(frame: contentView.bounds) + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] let child = CapturingView(frame: host.bounds) child.autoresizingMask = [.width, .height] host.addSubview(child) - contentView.addSubview(host) + container.addSubview(host, positioned: .above, relativeTo: contentView) let dividerPointInSplit = NSPoint( x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), @@ -3146,10 +8970,2346 @@ final class WindowBrowserHostViewTests: XCTestCase { let contentPointInHost = host.convert(contentPointInWindow, from: nil) XCTAssertTrue(host.hitTest(contentPointInHost) === child) } + + func testWindowBrowserPortalIgnoresHostedInspectorSplitResizeNotifications() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let appSplit = NSSplitView(frame: contentView.bounds) + appSplit.autoresizingMask = [.width, .height] + appSplit.isVertical = true + appSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))) + appSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: contentView.bounds.height))) + contentView.addSubview(appSplit) + + let inspectorSplit = NSSplitView(frame: host.bounds) + inspectorSplit.autoresizingMask = [.width, .height] + inspectorSplit.isVertical = true + inspectorSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))) + inspectorSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: host.bounds.height))) + host.addSubview(inspectorSplit) + + XCTAssertTrue( + WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( + appSplit, + window: window, + hostView: host + ), + "App layout splits should still trigger browser portal geometry sync" + ) + XCTAssertFalse( + WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( + inspectorSplit, + window: window, + hostView: host + ), + "Hosted DevTools/internal splits should not trigger browser portal geometry sync" + ) + } + + func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() { + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .cursorUpdate + ) + ) + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .mouseEntered + ) + ) + } + + func testDragHoverEventsPassThroughForSidebarReorderWithoutMouseButtonState() { + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.sidebarTabReorderType], + eventType: .cursorUpdate + ) + ) + } + + func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() { + XCTAssertFalse( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [.fileURL], + eventType: .cursorUpdate + ) + ) + } + + func testHostViewKeepsHostedInspectorDividerInteractive() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + // Underlying app layout split that should still be pass-through. + let appSplit = NSSplitView(frame: contentView.bounds) + appSplit.autoresizingMask = [.width, .height] + appSplit.isVertical = true + appSplit.dividerStyle = .thin + let appSplitDelegate = BonsplitMockSplitDelegate() + appSplit.delegate = appSplitDelegate + let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height)) + let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height)) + appSplit.addSubview(leading) + appSplit.addSubview(trailing) + contentView.addSubview(appSplit) + appSplit.adjustSubviews() + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + // WebKit inspector uses an internal split (page + console). Divider drags + // here must stay in hosted content, not pass through to appSplit behind it. + let inspectorSplit = NSSplitView(frame: host.bounds) + inspectorSplit.autoresizingMask = [.width, .height] + inspectorSplit.isVertical = false + inspectorSplit.dividerStyle = .thin + let inspectorDelegate = BonsplitMockSplitDelegate() + inspectorSplit.delegate = inspectorDelegate + let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160)) + let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99)) + inspectorSplit.addSubview(pageView) + inspectorSplit.addSubview(consoleView) + host.addSubview(inspectorSplit) + inspectorSplit.setPosition(160, ofDividerAt: 0) + inspectorSplit.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let appDividerPointInSplit = NSPoint( + x: appSplit.arrangedSubviews[0].frame.maxX + (appSplit.dividerThickness * 0.5), + y: appSplit.bounds.midY + ) + let appDividerPointInWindow = appSplit.convert(appDividerPointInSplit, to: nil) + let appDividerPointInHost = host.convert(appDividerPointInWindow, from: nil) + XCTAssertNil( + host.hitTest(appDividerPointInHost), + "Underlying app split divider should still pass through with a hosted inspector split present" + ) + + let dividerPointInInspector = NSPoint( + x: inspectorSplit.bounds.midX, + y: inspectorSplit.arrangedSubviews[0].frame.maxY + (inspectorSplit.dividerThickness * 0.5) + ) + let dividerPointInWindow = inspectorSplit.convert(dividerPointInInspector, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + let hit = host.hitTest(dividerPointInHost) + + XCTAssertNotNil( + hit, + "Inspector divider should receive hit-testing in hosted content, not pass through" + ) + XCTAssertFalse(hit === host) + if let hit { + XCTAssertTrue( + hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), + "Expected hit to remain inside inspector split subtree" + ) + } + } + + func testHostViewKeepsHostedVerticalInspectorDividerInteractiveAtSlotLeadingEdge() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let inspectorSplit = NSSplitView(frame: slot.bounds) + inspectorSplit.autoresizingMask = [.width, .height] + inspectorSplit.isVertical = true + inspectorSplit.dividerStyle = .thin + let inspectorDelegate = BonsplitMockSplitDelegate() + inspectorSplit.delegate = inspectorDelegate + let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height)) + let inspectorView = CapturingView( + frame: NSRect(x: 2, y: 0, width: slot.bounds.width - 2, height: slot.bounds.height) + ) + inspectorSplit.addSubview(pageView) + inspectorSplit.addSubview(inspectorView) + slot.addSubview(inspectorSplit) + inspectorSplit.setPosition(1, ofDividerAt: 0) + inspectorSplit.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSplit = NSPoint( + x: inspectorSplit.arrangedSubviews[0].frame.maxX + (inspectorSplit.dividerThickness * 0.5), + y: inspectorSplit.bounds.midY + ) + let dividerPointInWindow = inspectorSplit.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5) + XCTAssertTrue( + abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide, + "Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone" + ) + + let hit = host.hitTest(dividerPointInHost) + XCTAssertNotNil( + hit, + "Hosted vertical inspector divider should stay interactive even when collapsed onto the slot edge" + ) + XCTAssertFalse(hit === host) + if let hit { + XCTAssertTrue( + hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), + "Expected hit to remain inside hosted inspector split subtree at the slot edge" + ) + } + } + + func testHostViewPrefersNativeHostedInspectorSiblingDividerHit() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) + ) + slot.addSubview(pageView) + slot.addSubview(inspectorView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + let bodyPointInSlot = NSPoint(x: inspectorView.frame.minX + 18, y: slot.bounds.midY) + let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) + let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) + + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), + "Hosted right-docked inspector divider should stay on the native WebKit hit path when WebKit exposes a hittable inspector-side view. actual=\(String(describing: dividerHit))" + ) + let interiorHit = host.hitTest(bodyPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), + "Only the divider edge should be claimed; interior inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" + ) + } + + func testHostViewPrefersNativeNestedHostedInspectorSiblingDividerHit() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let wrapper = NSView(frame: slot.bounds) + wrapper.autoresizingMask = [.width, .height] + slot.addSubview(wrapper) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height)) + let inspectorContainer = NSView( + frame: NSRect(x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height) + ) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + wrapper.addSubview(pageView) + wrapper.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + let bodyPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 18, y: slot.bounds.midY) + let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) + let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) + + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), + "Portal host should prefer the native nested WebKit hit target on the right-docked divider when available. actual=\(String(describing: dividerHit))" + ) + let interiorHit = host.hitTest(bodyPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), + "Only the divider edge should be claimed; interior nested inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" + ) + } + + func testHostViewReappliesStoredHostedInspectorWidthAfterSlotLayoutReset() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let wrapper = NSView(frame: slot.bounds) + wrapper.autoresizingMask = [.width, .height] + slot.addSubview(wrapper) + + let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height) + let originalInspectorFrame = NSRect( + x: 92, + y: 0, + width: wrapper.bounds.width - 92, + height: wrapper.bounds.height + ) + let pageView = PrimaryPageProbeView(frame: originalPageFrame) + let inspectorContainer = NSView(frame: originalInspectorFrame) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + wrapper.addSubview(pageView) + wrapper.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + let draggedPageWidth = pageView.frame.width + let draggedInspectorMinX = inspectorContainer.frame.minX + XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) + XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) + + pageView.frame = originalPageFrame + inspectorContainer.frame = originalInspectorFrame + slot.needsLayout = true + slot.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) + XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) + } + + func testHostViewFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) + let inspectorView = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) + ) + slot.addSubview(pageView) + slot.addSubview(inspectorView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + dividerHit === host, + "Host should only take the manual fallback path when the right-docked divider edge is not natively hittable. actual=\(String(describing: dividerHit))" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(pageView.frame.width, 92) + XCTAssertGreaterThan(inspectorView.frame.minX, 92) + } + + func testHostViewFallsBackToManualHostedInspectorDragForLeftDockedInspector() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let inspectorView = TrailingEdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height) + ) + let pageView = PrimaryPageProbeView( + frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) + ) + slot.addSubview(inspectorView) + slot.addSubview(pageView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.maxX - 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Host should take the manual fallback path for a left-docked divider when the native edge is not hittable" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(inspectorView.frame.width, 92) + XCTAssertGreaterThan(pageView.frame.minX, 92) + } + + func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: slot.bounds.height)) + let inspectorView = WKInspectorProbeView(frame: slot.bounds) + slot.addSubview(pageView) + slot.addSubview(inspectorView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide) + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), + "Collapsed right-docked hosted inspector divider should stay on the native WebKit hit path while still beating the sidebar-resizer overlap zone. actual=\(String(describing: dividerHit))" + ) + } +} + +@MainActor +final class BrowserPanelHostContainerViewTests: XCTestCase { + private final class PrimaryPageProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class TrackingInspectorFrontendWebView: WKWebView { + private(set) var evaluatedJavaScript: [String] = [] + + @MainActor override func evaluateJavaScript( + _ javaScriptString: String, + completionHandler: (@MainActor @Sendable (Any?, (any Error)?) -> Void)? = nil + ) { + evaluatedJavaScript.append(javaScriptString) + completionHandler?(nil, nil) + } + } + + private final class WKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class EdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x <= 12 ? nil : self + } + } + + private final class TrailingEdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x >= bounds.maxX - 12 ? nil : self + } + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + func testBrowserPanelHostPrefersNativeHostedInspectorSiblingDividerHit() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = NSView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let bodyPointInHost = NSPoint(x: inspectorContainer.frame.minX + 18, y: host.bounds.midY) + let interiorHit = host.hitTest(bodyPointInHost) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Browser panel host should claim the right-docked divider edge for the manual resize path" + ) + XCTAssertTrue( + interiorHit == nil || interiorHit !== host, + "Only the divider edge should be claimed; interior inspector hits should not be stolen by the host. actual=\(String(describing: interiorHit))" + ) + } + + func testBrowserPanelHostClaimsCollapsedHostedInspectorSiblingDividerAtLeadingEdge() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: webViewRoot.bounds.height)) + let inspectorContainer = NSView(frame: webViewRoot.bounds) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Collapsed right-docked divider should stay on the manual browser-panel resize path while beating the sidebar-resizer overlap" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 36, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(pageView.frame.width, 0) + XCTAssertGreaterThan(inspectorContainer.frame.minX, 0) + } + + func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, + "The custom DevTools divider should remain draggable at the top edge of the browser pane" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, + "The custom DevTools divider should remain draggable at the bottom edge of the browser pane" + ) + } + + func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Browser panel host should only take the manual fallback path when the divider edge is not natively hittable" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(pageView.frame.width, 92) + XCTAssertGreaterThan(inspectorContainer.frame.minX, 92) + } + + func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThanOrEqual( + inspectorContainer.frame.width, + 120, + "Shrinking the DevTools pane should clamp to a recoverable minimum width" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, + "After clamping, the DevTools divider should still be draggable near the top edge" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, + "After clamping, the DevTools divider should still be draggable near the bottom edge" + ) + } + + func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), + "A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path" + ) + XCTAssertTrue( + pageView.superview === inspectorView.superview && pageView.superview !== slotView, + "Promotion should move both hosted inspector siblings into the managed side-dock container" + ) + XCTAssertEqual( + pageView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Promotion should normalize stale page heights to the host height so the page layer stops covering the divider" + ) + XCTAssertEqual( + inspectorView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Promotion should normalize the inspector height to the host height" + ) + } + + func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), + "The managed side-dock path should be active before drag assertions run" + ) + + let initialPageWidth = pageView.frame.width + let initialInspectorWidth = inspectorView.frame.width + let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan( + inspectorView.frame.width, + initialInspectorWidth, + "Right-docked DevTools should expand when the divider is dragged left" + ) + XCTAssertLessThan( + pageView.frame.width, + initialPageWidth, + "Expanding right-docked DevTools should shrink the page width" + ) + } + + func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil) + host.setFrameSize(NSSize(width: 210, height: host.frame.height)) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertGreaterThanOrEqual( + inspectorView.frame.width, + 120, + "Automatic pane resize should honor the same minimum hosted inspector width as manual dragging" + ) + XCTAssertEqual( + inspectorView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Automatic shrink should keep the inspector vertically normalized to the host height" + ) + } + + func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)) + let inspectorView = TrackingInspectorFrontendWebView( + frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + host.setFrameSize(NSSize(width: 210, height: host.frame.height)) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }), + "Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout" + ) + XCTAssertTrue( + inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }), + "Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls" + ) + } + + func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + guard let managedContainer = pageView.superview else { + XCTFail("Expected managed side-dock container") + return + } + let draggedPageFrame = pageView.frame + let draggedInspectorFrame = inspectorView.frame + + managedContainer.setFrameSize( + NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24) + ) + + XCTAssertEqual( + pageView.frame.origin.x, + draggedPageFrame.origin.x, + accuracy: 0.5, + "Managed side-dock container should not autoresize the page back to a stale divider position" + ) + XCTAssertEqual( + pageView.frame.width, + draggedPageFrame.width, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout" + ) + XCTAssertEqual( + inspectorView.frame.origin.x, + draggedInspectorFrame.origin.x, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged inspector origin" + ) + XCTAssertEqual( + inspectorView.frame.width, + draggedInspectorFrame.width, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged inspector width" + ) + } + + func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let inspectorContainer = TrailingEdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height) + ) + let pageView = PrimaryPageProbeView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + webViewRoot.addSubview(inspectorContainer) + webViewRoot.addSubview(pageView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.maxX - 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Browser panel host should take the manual fallback path for a left-docked divider when the native edge is not hittable" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(inspectorContainer.frame.width, 92) + XCTAssertGreaterThan(pageView.frame.minX, 92) + } + + func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView( + frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height) + ) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height) + let originalInspectorFrame = NSRect( + x: 92, + y: 0, + width: webViewRoot.bounds.width - 92, + height: webViewRoot.bounds.height + ) + let pageView = PrimaryPageProbeView(frame: originalPageFrame) + let inspectorContainer = NSView(frame: originalInspectorFrame) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + let draggedPageWidth = pageView.frame.width + let draggedInspectorMinX = inspectorContainer.frame.minX + XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) + XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) + + pageView.frame = originalPageFrame + inspectorContainer.frame = originalInspectorFrame + host.needsLayout = true + host.layoutSubtreeIfNeeded() + + XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) + XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) + } + + func testWindowBrowserSlotPinsHostedWebViewWithAutoresizingForAttachedInspector() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 180)) + let webView = WKWebView(frame: .zero) + slot.addSubview(webView) + + slot.pinHostedWebView(webView) + slot.frame = NSRect(x: 0, y: 0, width: 300, height: 220) + slot.layoutSubtreeIfNeeded() + + XCTAssertTrue(webView.translatesAutoresizingMaskIntoConstraints) + XCTAssertEqual(webView.autoresizingMask, [.width, .height]) + XCTAssertEqual(webView.frame, slot.bounds) + } + + func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180)) + let webView = WKWebView(frame: .zero) + slot.addSubview(webView) + slot.pinHostedWebView(webView) + XCTAssertEqual(webView.frame, slot.bounds) + + let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180)) + webView.removeFromSuperview() + externalHost.addSubview(webView) + webView.frame = externalHost.bounds + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + + slot.addSubview(webView) + slot.pinHostedWebView(webView) + + slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180) + slot.layoutSubtreeIfNeeded() + + XCTAssertEqual( + webView.frame, + slot.bounds, + "Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host" + ) + } +} + +@MainActor +final class CmuxWebViewDragRoutingTests: XCTestCase { + func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() { + XCTAssertTrue( + CmuxWebView.shouldRejectInternalPaneDrag([ + DragOverlayRoutingPolicy.bonsplitTabTransferType, + NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"), + ]) + ) + } + + func testAllowsRegularExternalFileDrops() { + XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL])) + } +} + +#if compiler(>=6.2) +@available(macOS 26.0, *) +private struct DragConfigurationOperationsSnapshot: Equatable { + let allowCopy: Bool + let allowMove: Bool + let allowDelete: Bool + let allowAlias: Bool +} + +@available(macOS 26.0, *) +private enum DragConfigurationSnapshotError: Error { + case missingBoolField(primary: String, fallback: String?) +} + +@available(macOS 26.0, *) +private func dragConfigurationOperationsSnapshot<T>(from operations: T) throws -> DragConfigurationOperationsSnapshot { + let mirror = Mirror(reflecting: operations) + + func readBool(_ primary: String, fallback: String? = nil) throws -> Bool { + if let value = mirror.descendant(primary) as? Bool { + return value + } + if let fallback, let value = mirror.descendant(fallback) as? Bool { + return value + } + throw DragConfigurationSnapshotError.missingBoolField(primary: primary, fallback: fallback) + } + + return try DragConfigurationOperationsSnapshot( + allowCopy: readBool("allowCopy", fallback: "_allowCopy"), + allowMove: readBool("allowMove", fallback: "_allowMove"), + allowDelete: readBool("allowDelete", fallback: "_allowDelete"), + allowAlias: readBool("allowAlias", fallback: "_allowAlias") + ) +} + +@MainActor +final class InternalTabDragConfigurationTests: XCTestCase { + func testDisablesExternalOperationsForInternalTabDrags() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Requires macOS 26 drag configuration APIs") + } + + let configuration = InternalTabDragConfigurationProvider.value + let withinApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsWithinApp) + let outsideApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsOutsideApp) + + XCTAssertEqual( + withinApp, + DragConfigurationOperationsSnapshot( + allowCopy: false, + allowMove: true, + allowDelete: false, + allowAlias: false + ) + ) + + XCTAssertEqual( + outsideApp, + DragConfigurationOperationsSnapshot( + allowCopy: false, + allowMove: false, + allowDelete: false, + allowAlias: false + ) + ) + } +} +#endif + +@MainActor +final class BrowserPaneDropRoutingTests: XCTestCase { + func testVerticalZonesFollowAppKitCoordinates() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size), + .top + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size), + .bottom + ) + } + + func testTopChromeHeightPushesTopSplitThresholdIntoWebView() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 110), + in: size, + topChromeHeight: 36 + ), + .center + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 150), + in: size, + topChromeHeight: 36 + ), + .top + ) + } + + func testHitTestingCapturesOnlyForRelevantDragEvents() { + XCTAssertTrue( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .cursorUpdate + ) + ) + XCTAssertFalse( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .leftMouseDown + ) + ) + XCTAssertFalse( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [.fileURL], + eventType: .cursorUpdate + ) + ) + } + + func testCenterDropOnSamePaneIsNoOp() { + let paneId = PaneID(id: UUID()) + let target = BrowserPaneDropContext( + workspaceId: UUID(), + panelId: UUID(), + paneId: paneId + ) + let transfer = BrowserPaneDragTransfer( + tabId: UUID(), + sourcePaneId: paneId.id, + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + + XCTAssertEqual( + BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center), + .noOp + ) + } + + func testRightEdgeDropBuildsSplitMoveAction() { + let paneId = PaneID(id: UUID()) + let target = BrowserPaneDropContext( + workspaceId: UUID(), + panelId: UUID(), + paneId: paneId + ) + let tabId = UUID() + let transfer = BrowserPaneDragTransfer( + tabId: tabId, + sourcePaneId: UUID(), + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + + XCTAssertEqual( + BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right), + .move( + tabId: tabId, + targetWorkspaceId: target.workspaceId, + targetPane: paneId, + splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false) + ) + ) + } + + func testDecodeTransferPayloadReadsTabAndSourcePane() { + let tabId = UUID() + let sourcePaneId = UUID() + let payload = try! JSONSerialization.data( + withJSONObject: [ + "tab": ["id": tabId.uuidString], + "sourcePaneId": sourcePaneId.uuidString, + "sourceProcessId": ProcessInfo.processInfo.processIdentifier, + ] + ) + + let transfer = BrowserPaneDragTransfer.decode(from: payload) + + XCTAssertEqual(transfer?.tabId, tabId) + XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId) + XCTAssertTrue(transfer?.isFromCurrentProcess == true) + } +} + +@MainActor +final class WindowBrowserSlotViewTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private func advanceAnimations() { + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + + func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let slot = WindowBrowserSlotView(frame: container.bounds) + container.addSubview(slot) + let child = CapturingView(frame: slot.bounds) + child.autoresizingMask = [.width, .height] + slot.addSubview(child) + + slot.setDropZoneOverlay(zone: .right) + container.layoutSubtreeIfNeeded() + + guard let overlay = container.subviews.first(where: { + $0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + }) else { + XCTFail("Expected browser slot drop-zone overlay") + return + } + + XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view") + XCTAssertFalse(overlay.isHidden) + XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.width, 96, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 92, accuracy: 0.5) + XCTAssertNil(overlay.hitTest(NSPoint(x: 120, y: 50)), "Overlay should never intercept pointer hits") + XCTAssertTrue(slot.hitTest(NSPoint(x: 120, y: 50)) === child) + + slot.setDropZoneOverlay(zone: nil) + advanceAnimations() + XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay") + } + + func testTopDropZoneOverlayUsesFullBrowserContentHeight() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let slot = WindowBrowserSlotView(frame: container.bounds) + container.addSubview(slot) + + slot.setPaneTopChromeHeight(20) + slot.setDropZoneOverlay(zone: .top) + container.layoutSubtreeIfNeeded() + + guard let overlay = container.subviews.first(where: { + String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + }) else { + XCTFail("Expected browser slot drop-zone overlay") + return + } + + XCTAssertFalse(overlay.isHidden) + XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5) + XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY) + XCTAssertEqual(slot.layer?.masksToBounds, true) + + slot.setDropZoneOverlay(zone: nil) + advanceAnimations() + XCTAssertEqual(slot.layer?.masksToBounds, true) + } +} + +@MainActor +final class WindowDragHandleHitTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class HostContainerView: NSView {} + private final class BlockingTopHitContainerView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + private final class PassThroughProbeView: NSView { + var onHitTest: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + onHitTest?() + return nil + } + } + private final class PassiveHostContainerView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + return super.hitTest(point) ?? self + } + } + + private final class MutatingSiblingView: NSView { + weak var container: NSView? + private var didMutate = false + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + guard !didMutate, let container else { return nil } + didMutate = true + let transient = NSView(frame: .zero) + container.addSubview(transient) + transient.removeFromSuperview() + return nil + } + } + + private final class ReentrantDragHandleView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self, eventType: .leftMouseDown, eventWindow: self.window) + return shouldCapture ? self : nil + } + } + + /// A sibling view whose hitTest re-enters windowDragHandleShouldCaptureHit, + /// simulating the crash path where sibling.hitTest triggers a SwiftUI layout + /// pass that calls back into the drag handle's hit resolution. + private final class ReentrantSiblingView: NSView { + weak var dragHandle: NSView? + var reenteredResult: Bool? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point), let dragHandle else { return nil } + // Simulate the re-entry: during sibling hit test, SwiftUI layout + // calls windowDragHandleShouldCaptureHit on the drag handle again. + reenteredResult = windowDragHandleShouldCaptureHit( + point, in: dragHandle, eventType: .leftMouseDown, eventWindow: dragHandle.window + ) + return nil + } + } + + func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown), + "Empty titlebar space should drag the window" + ) + } + + func testDragHandleYieldsWhenSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + container.addSubview(folderIconHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown), + "Interactive titlebar controls should receive the mouse event" + ) + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown)) + } + + func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + hidden.isHidden = true + container.addSubview(hidden) + + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown)) + } + + func testDragHandleDoesNotCaptureOutsideBounds() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle, eventType: .leftMouseDown)) + } + + func testDragHandleSkipsCaptureForPassivePointerEvents() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let point = NSPoint(x: 180, y: 18) + XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .mouseMoved)) + XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .cursorUpdate)) + XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: nil)) + XCTAssertTrue(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown)) + } + + func testDragHandleSkipsForeignLeftMouseDownDuringLaunch() { + let point = NSPoint(x: 180, y: 18) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let container = NSView(frame: contentView.bounds) + container.autoresizingMask = [.width, .height] + contentView.addSubview(container) + + let dragHandle = NSView(frame: container.bounds) + dragHandle.autoresizingMask = [.width, .height] + container.addSubview(dragHandle) + + let foreignWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + defer { foreignWindow.orderOut(nil) } + + XCTAssertFalse( + windowDragHandleShouldCaptureHit( + point, + in: dragHandle, + eventType: .leftMouseDown, + eventWindow: nil + ), + "Launch activation events without a matching window should not trigger drag-handle hierarchy walk" + ) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit( + point, + in: dragHandle, + eventType: .leftMouseDown, + eventWindow: foreignWindow + ), + "Left mouse-down events for a different window should be treated as passive" + ) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit( + point, + in: dragHandle, + eventType: .leftMouseDown, + eventWindow: window + ), + "Left mouse-down events for this window should still capture empty titlebar space" + ) + } + + func testPassiveHostingTopHitClassification() { + XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero))) + XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero))) + } + + func testDragHandleIgnoresPassiveHostSiblingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + container.addSubview(passiveHost) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown), + "Passive host wrappers should not block titlebar drag capture" + ) + } + + func testDragHandleRespectsInteractiveChildInsidePassiveHost() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + passiveHost.addSubview(folderControl) + container.addSubview(passiveHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown), + "Interactive controls inside passive host wrappers should still receive hits" + ) + } + + func testTopHitResolutionStateIsScopedPerWindow() { + let point = NSPoint(x: 100, y: 18) + + let outerWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { outerWindow.orderOut(nil) } + guard let outerContentView = outerWindow.contentView else { + XCTFail("Expected outer content view") + return + } + let outerContainer = NSView(frame: outerContentView.bounds) + outerContainer.autoresizingMask = [.width, .height] + outerContentView.addSubview(outerContainer) + let outerDragHandle = NSView(frame: outerContainer.bounds) + outerDragHandle.autoresizingMask = [.width, .height] + outerContainer.addSubview(outerDragHandle) + + let nestedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { nestedWindow.orderOut(nil) } + guard let nestedContentView = nestedWindow.contentView else { + XCTFail("Expected nested content view") + return + } + let nestedContainer = BlockingTopHitContainerView(frame: nestedContentView.bounds) + nestedContainer.autoresizingMask = [.width, .height] + nestedContentView.addSubview(nestedContainer) + let nestedDragHandle = NSView(frame: nestedContainer.bounds) + nestedDragHandle.autoresizingMask = [.width, .height] + nestedContainer.addSubview(nestedDragHandle) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow), + "Nested window drag handle should be blocked by top-hit titlebar container" + ) + + var nestedCaptureResult: Bool? + let probe = PassThroughProbeView(frame: outerContainer.bounds) + probe.autoresizingMask = [.width, .height] + probe.onHitTest = { + nestedCaptureResult = windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow) + } + outerContainer.addSubview(probe) + + _ = windowDragHandleShouldCaptureHit(point, in: outerDragHandle, eventType: .leftMouseDown, eventWindow: outerWindow) + + XCTAssertEqual( + nestedCaptureResult, + false, + "Top-hit recursion in one window must not disable top-hit resolution in another window" + ) + } + + func testDragHandleRemainsStableWhenSiblingMutatesSubviewsDuringHitTest() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let mutatingSibling = MutatingSiblingView(frame: container.bounds) + mutatingSibling.container = container + container.addSubview(mutatingSibling) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown), + "Subview mutations during hit testing should not crash or break drag-handle capture" + ) + } + + func testDragHandleSiblingHitTestReentrancyDoesNotCrash() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let reentrantSibling = ReentrantSiblingView(frame: container.bounds) + reentrantSibling.dragHandle = dragHandle + container.addSubview(reentrantSibling) + + // The outer call enters the sibling walk, which calls + // reentrantSibling.hitTest(), which re-enters + // windowDragHandleShouldCaptureHit. Without the re-entrancy guard + // this would trigger a Swift exclusive-access violation (SIGABRT). + let outerResult = windowDragHandleShouldCaptureHit( + NSPoint(x: 110, y: 18), in: dragHandle, eventType: .leftMouseDown + ) + XCTAssertTrue(outerResult, "Outer call should still capture when sibling returns nil") + XCTAssertEqual( + reentrantSibling.reenteredResult, false, + "Re-entrant call should bail out (return false) instead of crashing" + ) + } + + func testDragHandleTopHitResolutionSurvivesSameWindowReentrancy() { + let point = NSPoint(x: 180, y: 18) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let container = NSView(frame: contentView.bounds) + container.autoresizingMask = [.width, .height] + contentView.addSubview(container) + + let dragHandle = ReentrantDragHandleView(frame: container.bounds) + dragHandle.autoresizingMask = [.width, .height] + container.addSubview(dragHandle) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown, eventWindow: window), + "Reentrant same-window top-hit resolution should not trigger exclusivity crashes" + ) + } +} + +#if DEBUG +@MainActor +final class SidebarWorkspaceShortcutHintMetricsTests: XCTestCase { + override func setUp() { + super.setUp() + SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting() + } + + override func tearDown() { + SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting() + super.tearDown() + } + + func testHintWidthCachesRepeatedMeasurements() { + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 0) + + let first = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1") + XCTAssertGreaterThan(first, 0) + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1) + + let second = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1") + XCTAssertEqual(second, first) + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1) + + _ = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘2") + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 2) + } + + func testSlotWidthAppliesMinimumAndDebugInset() { + let nilLabelWidth = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: nil, debugXOffset: 999) + XCTAssertEqual(nilLabelWidth, 28) + + let base = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 0) + let widened = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 10) + XCTAssertGreaterThan(widened, base) + } +} +#endif + +@MainActor +final class DraggableFolderHitTests: XCTestCase { + func testFolderHitTestReturnsContainerWhenInsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else { + XCTFail("Expected folder icon to capture inside hit") + return + } + XCTAssertTrue(hit === folderView) + } + + func testFolderHitTestReturnsNilOutsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8))) + } + + func testFolderIconDisablesWindowMoveBehavior() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertFalse(folderView.mouseDownCanMoveWindow) + } +} + +@MainActor +final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { + func testLeadingInsetViewDoesNotParticipateInHitTesting() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10))) + } + + func testLeadingInsetViewCannotMoveWindowViaMouseDown() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertFalse(view.mouseDownCanMoveWindow) + } +} + +@MainActor +final class FolderWindowMoveSuppressionTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + } + + func testSuppressionDisablesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, true) + XCTAssertFalse(window.isMovable) + } + + func testSuppressionPreservesAlreadyImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testRestoreAppliesPreviousMovableState() { + let window = makeWindow() + window.isMovable = false + + restoreWindowDragging(window: window, previousMovableState: true) + XCTAssertTrue(window.isMovable) + + restoreWindowDragging(window: window, previousMovableState: false) + XCTAssertFalse(window.isMovable) + } + + func testWindowDragSuppressionDepthLifecycle() { + let window = makeWindow() + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testWindowDragSuppressionIsReferenceCounted() { + let window = makeWindow() + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(beginWindowDragSuppression(window: window), 2) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 2) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testTemporaryWindowMovableEnableRestoresImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testTemporaryWindowMovableEnablePreservesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, true) + XCTAssertTrue(window.isMovable) + } +} + +@MainActor +final class WindowMoveSuppressionHitPathTests: XCTestCase { + private func makeWindowWithContentView() -> (NSWindow, NSView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + return (window, contentView) + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + func testSuppressionHitPathRecognizesFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView)) + } + + func testSuppressionHitPathRecognizesDescendantOfFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + let child = NSView(frame: .zero) + folderView.addSubview(child) + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child)) + } + + func testSuppressionHitPathIgnoresUnrelatedViews() { + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero))) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil)) + } + + func testSuppressionEventPathRecognizesFolderHitInsideWindow() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16) + contentView.addSubview(folderView) + + let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window) + + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event)) + } + + func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + contentView.addSubview(plainView) + + let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down)) + + let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged)) + } +} + +@MainActor +final class CommandPaletteOverlayPromotionPolicyTests: XCTestCase { + func testShouldPromoteWhenBecomingVisible() { + XCTAssertTrue( + CommandPaletteOverlayPromotionPolicy.shouldPromote( + previouslyVisible: false, + isVisible: true + ) + ) + } + + func testShouldNotPromoteWhenAlreadyVisible() { + XCTAssertFalse( + CommandPaletteOverlayPromotionPolicy.shouldPromote( + previouslyVisible: true, + isVisible: true + ) + ) + } + + func testShouldNotPromoteWhenHidden() { + XCTAssertFalse( + CommandPaletteOverlayPromotionPolicy.shouldPromote( + previouslyVisible: true, + isVisible: false + ) + ) + XCTAssertFalse( + CommandPaletteOverlayPromotionPolicy.shouldPromote( + previouslyVisible: false, + isVisible: false + ) + ) + } } @MainActor final class GhosttySurfaceOverlayTests: XCTestCase { + private final class ScrollProbeSurfaceView: GhosttyNSView { + private(set) var scrollWheelCallCount = 0 + + override func scrollWheel(with event: NSEvent) { + scrollWheelCallCount += 1 + } + } + + private func findEditableTextField(in view: NSView) -> NSTextField? { + if let field = view as? NSTextField, field.isEditable { + return field + } + for subview in view.subviews { + if let field = findEditableTextField(in: subview) { + return field + } + } + return nil + } + + func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surfaceView = ScrollProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else { + XCTFail("Expected hosted terminal scroll view") + return + } + XCTAssertFalse( + scrollView.acceptsFirstResponder, + "Host scroll view should not become first responder and steal terminal shortcuts" + ) + + _ = window.makeFirstResponder(nil) + + guard let cgEvent = CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 2, + wheel1: 0, + wheel2: -12, + wheel3: 0 + ), let scrollEvent = NSEvent(cgEvent: cgEvent) else { + XCTFail("Expected scroll wheel event") + return + } + + scrollView.scrollWheel(with: scrollEvent) + + XCTAssertEqual( + surfaceView.scrollWheelCallCount, + 1, + "Trackpad wheel events should be forwarded directly to Ghostty surface scrolling" + ) + XCTAssertTrue( + window.firstResponder === surfaceView, + "Scroll wheel handling should keep keyboard focus on terminal surface" + ) + } + func testInactiveOverlayVisibilityTracksRequestedState() { let hostedView = GhosttySurfaceScrollView( surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50)) @@ -3164,10 +11324,382 @@ final class GhosttySurfaceOverlayTests: XCTestCase { state = hostedView.debugInactiveOverlayState() XCTAssertTrue(state.isHidden) } + + func testWindowResignKeyClearsFocusedTerminalFirstResponder() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + ) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + hostedView.moveFocus() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue( + hostedView.isSurfaceViewFirstResponder(), + "Expected terminal surface to be first responder before window blur" + ) + + NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + hostedView.isSurfaceViewFirstResponder(), + "Window blur should force terminal surface to resign first responder" + ) + } + + func testSearchOverlayMountsAndUnmountsWithSearchState() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + XCTAssertFalse(hostedView.debugHasSearchOverlay()) + + let searchState = TerminalSurface.SearchState(needle: "example") + hostedView.setSearchOverlay(searchState: searchState) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + hostedView.setSearchOverlay(searchState: nil) + XCTAssertFalse(hostedView.debugHasSearchOverlay()) + } + + func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() { + _ = NSApplication.shared + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil + window.orderOut(nil) + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let searchState = TerminalSurface.SearchState(needle: "") + surface.searchState = searchState + hostedView.setSearchOverlay(searchState: searchState) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let searchField = findEditableTextField(in: hostedView) else { + XCTFail("Expected mounted find text field") + return + } + window.makeFirstResponder(searchField) + + var escapeKeyUpCount = 0 + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + guard keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 53 else { return } + escapeKeyUpCount += 1 + } + + let timestamp = ProcessInfo.processInfo.systemUptime + guard let escapeKeyDown = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + ), let escapeKeyUp = NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: [], + timestamp: timestamp + 0.001, + windowNumber: window.windowNumber, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + ) else { + XCTFail("Failed to construct Escape key events") + return + } + + NSApp.sendEvent(escapeKeyDown) + NSApp.sendEvent(escapeKeyUp) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNil(surface.searchState, "Escape should dismiss find overlay when search text is empty") + XCTAssertEqual( + escapeKeyUpCount, + 0, + "Escape used to dismiss find overlay must not pass through to the terminal key-up path" + ) + } + + @MainActor + func testKeyboardCopyModeIndicatorMountsAndUnmounts() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) + + hostedView.syncKeyStateIndicator(text: "vim") + XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator()) + + hostedView.syncKeyStateIndicator(text: nil) + XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) + } + + @MainActor + func testDropHoverOverlayAttachesToParentContainerInsteadOfHostedTerminalView() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 120)) + let surfaceView = GhosttyNSView(frame: .zero) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = container.bounds + container.addSubview(hostedView) + + hostedView.setDropZoneOverlay(zone: .right) + container.layoutSubtreeIfNeeded() + + let state = hostedView.debugDropZoneOverlayState() + XCTAssertFalse(state.isHidden) + XCTAssertFalse( + state.isAttachedToHostedView, + "Drop-hover overlay should be mounted outside the hosted terminal view" + ) + XCTAssertTrue( + state.isAttachedToParentContainer, + "Drop-hover overlay should be mounted in the parent container so it cannot perturb terminal layout" + ) + XCTAssertEqual(state.frame.origin.x, 120, accuracy: 0.5) + XCTAssertEqual(state.frame.origin.y, 4, accuracy: 0.5) + XCTAssertEqual(state.frame.size.width, 116, accuracy: 0.5) + XCTAssertEqual(state.frame.size.height, 112, accuracy: 0.5) + + hostedView.setDropZoneOverlay(zone: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + XCTAssertTrue(hostedView.debugDropZoneOverlayState().isHidden) + } + + func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws { +#if DEBUG + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + hostedView.reconcileGeometryNow() + surface.releaseSurfaceForTesting() + XCTAssertNil(surface.surface, "Surface should be nil after test release helper") + + hostedView.reconcileGeometryNow() + surface.forceRefresh() + XCTAssertNil(surface.surface, "Force refresh should no-op when runtime surface is nil") +#else + throw XCTSkip("Debug-only regression test") +#endif + } + + func testSearchOverlayMountDoesNotRetainTerminalSurface() { + weak var weakSurface: TerminalSurface? + + let hostedView: GhosttySurfaceScrollView = { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + weakSurface = surface + let hostedView = surface.hostedView + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "retain-check")) + return hostedView + }() + + RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + XCTAssertNil(weakSurface, "Mounted search overlay must not retain TerminalSurface") + } + + func testSearchOverlaySurvivesPortalRebindDuringSplitLikeChurn() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowTerminalPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorA = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 140)) + let anchorB = NSView(frame: NSRect(x: 220, y: 20, width: 180, height: 140)) + contentView.addSubview(anchorA) + contentView.addSubview(anchorB) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split")) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchorB, visibleInUI: true) + XCTAssertTrue( + hostedView.debugHasSearchOverlay(), + "Split-like anchor churn should not unmount terminal search overlay" + ) + } + + func testSearchOverlaySurvivesPortalVisibilityToggleDuringWorkspaceSwitchLikeChurn() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowTerminalPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 220, height: 160)) + contentView.addSubview(anchor) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace")) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchor, visibleInUI: false) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) + XCTAssertTrue( + hostedView.debugHasSearchOverlay(), + "Workspace-switch-like visibility toggles should not unmount terminal search overlay" + ) + } } @MainActor final class TerminalWindowPortalLifecycleTests: XCTestCase { + private final class ContentViewCountingWindow: NSWindow { + var contentViewReadCount = 0 + + override var contentView: NSView? { + get { + contentViewReadCount += 1 + return super.contentView + } + set { + super.contentView = newValue + } + } + } + + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + func testPortalHostInstallsAboveContentViewForVisibility() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -3197,6 +11729,54 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { ) } + func testTerminalPortalHostStaysBelowBrowserPortalHostWhenBothAreInstalled() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + let browserPortal = WindowBrowserPortal(window: window) + let terminalPortal = WindowTerminalPortal(window: window) + _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + func assertHostOrder(_ message: String) { + guard let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }), + let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }) else { + XCTFail("Expected both portal hosts in same container") + return + } + + XCTAssertLessThan( + terminalHostIndex, + browserHostIndex, + message + ) + } + + assertHostOrder("Terminal portal host should start below browser portal host") + + let anchor = NSView(frame: NSRect(x: 24, y: 24, width: 220, height: 150)) + contentView.addSubview(anchor) + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + terminalPortal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + terminalPortal.synchronizeHostedViewForAnchor(anchor) + + assertHostOrder("Terminal portal bind/sync should not rise above the browser portal host") + } + func testRegistryPrunesPortalWhenWindowCloses() { let baseline = TerminalWindowPortalRegistry.debugPortalCount() let window = NSWindow( @@ -3248,6 +11828,38 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView") } + func testSynchronizeReusesInstalledTargetWithoutRepeatedContentViewLookup() { + let window = ContentViewCountingWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120)) + contentView.addSubview(anchor) + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80)) + ) + portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + + let baselineReads = window.contentViewReadCount + for _ in 0..<25 { + portal.synchronizeHostedViewForAnchor(anchor) + } + + XCTAssertEqual( + window.contentViewReadCount, + baselineReads, + "Repeated synchronize calls should reuse installed target instead of repeatedly reading window.contentView" + ) + } + func testTerminalViewAtWindowPointResolvesPortalHostedSurface() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), @@ -3357,10 +11969,150 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Promoting z-priority should bring an already-visible terminal to front" ) } + + func testHiddenPortalDefersRevealUntilFrameHasUsableSize() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let portal = WindowTerminalPortal(window: window) + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 280, height: 220)) + contentView.addSubview(anchor) + + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + XCTAssertFalse(hosted.isHidden, "Healthy geometry should be visible") + + // Collapse to a tiny frame first. + anchor.frame = NSRect(x: 160.5, y: 1037.0, width: 79.0, height: 0.0) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue(hosted.isHidden, "Tiny geometry should hide the portal-hosted terminal") + + // Then restore to a non-zero but still too-small frame. It should remain hidden. + anchor.frame = NSRect(x: 160.9, y: 1026.5, width: 93.6, height: 10.3) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue( + hosted.isHidden, + "Portal should defer reveal until geometry reaches a usable size" + ) + + // Once the frame is large enough again, reveal should resume. + anchor.frame = NSRect(x: 40, y: 40, width: 180, height: 40) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable") + } + + func testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 220, height: 160)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56)) + shiftedContainer.addSubview(anchor) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + shiftedContainer.frame.origin.x += 96 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + + let shiftedWindowPoint = anchor.convert(anchorCenter, to: nil) + XCTAssertNotEqual(originalWindowPoint.x, shiftedWindowPoint.x, accuracy: 0.5) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Before the external geometry sync, hit-testing should still point at the stale portal location" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "The stale portal position should be cleared after the scheduled external geometry sync" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" + ) + } } @MainActor final class BrowserWindowPortalLifecycleTests: XCTestCase { + private final class TrackingPortalWebView: WKWebView { + private(set) var displayIfNeededCount = 0 + private(set) var reattachRenderingStateCount = 0 + + override func displayIfNeeded() { + displayIfNeededCount += 1 + super.displayIfNeeded() + } + + @objc(_enterInWindow) + func cmuxUnitTestEnterInWindow() { + reattachRenderingStateCount += 1 + } + + @objc(_endDeferringViewInWindowChangesSync) + func cmuxUnitTestEndDeferringViewInWindowChangesSync() { + reattachRenderingStateCount += 1 + } + } + + private final class WKInspectorProbeView: NSView {} + private func realizeWindowLayout(_ window: NSWindow) { window.makeKeyAndOrderFront(nil) window.displayIfNeeded() @@ -3369,6 +12121,19 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { window.contentView?.layoutSubtreeIfNeeded() } + private func advanceAnimations() { + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + + private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? { + let candidates = slot.subviews + (slot.superview?.subviews ?? []) + return candidates.first(where: { + $0 !== slot && + $0 !== webView && + String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + }) + } + func testPortalHostInstallsAboveContentViewForVisibility() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -3399,6 +12164,60 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { ) } + func testBrowserPortalHostStaysAboveTerminalPortalHostDuringPortalChurn() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + let browserPortal = WindowBrowserPortal(window: window) + let terminalPortal = WindowTerminalPortal(window: window) + _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + func assertHostOrder(_ message: String) { + guard let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), + let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }) else { + XCTFail("Expected both portal hosts in same container") + return + } + + XCTAssertGreaterThan( + browserHostIndex, + terminalHostIndex, + message + ) + } + + assertHostOrder("Browser portal host should start above terminal portal host") + + let terminalAnchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 140)) + contentView.addSubview(terminalAnchor) + let terminalHostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + terminalPortal.bind(hostedView: terminalHostedView, to: terminalAnchor, visibleInUI: true) + terminalPortal.synchronizeHostedViewForAnchor(terminalAnchor) + assertHostOrder("Terminal portal sync should not rise above the browser portal host") + + let browserAnchor = NSView(frame: NSRect(x: 240, y: 20, width: 220, height: 140)) + contentView.addSubview(browserAnchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + browserPortal.bind(webView: webView, to: browserAnchor, visibleInUI: true) + browserPortal.synchronizeWebViewForAnchor(browserAnchor) + assertHostOrder("Browser portal sync should keep browser panes above portal-hosted terminals") + } + func testAnchorRebindKeepsWebViewInStablePortalSuperview() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), @@ -3479,6 +12298,46 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5) } + func testPortalClipsAnchorFrameThroughAncestorBounds() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let clipView = NSView(frame: NSRect(x: 60, y: 40, width: 150, height: 120)) + contentView.addSubview(clipView) + + // Simulate SwiftUI/AppKit reporting an anchor wider than the actual visible pane. + let anchor = NSView(frame: NSRect(x: -30, y: 0, width: 220, height: 120)) + clipView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + clipView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + XCTAssertFalse(slot.isHidden, "Ancestor clipping should keep the browser visible in the real pane") + XCTAssertEqual(slot.frame.origin.x, 60, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, 40, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, 150, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, 120, accuracy: 0.5) + } + func testPortalSyncNormalizesOutOfBoundsWebFrame() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), @@ -3518,6 +12377,419 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5) } + func testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160)) + let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration()) + let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160)) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(webView) + slot.addSubview(inspectorContainer) + + webView.translatesAutoresizingMaskIntoConstraints = false + webView.autoresizingMask = [] + slot.pinHostedWebView(webView) + + XCTAssertEqual( + webView.frame.maxX, + inspectorContainer.frame.minX, + accuracy: 0.5, + "Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split" + ) + XCTAssertLessThan( + webView.frame.width, + slot.bounds.width, + "The page frame should stay narrower than the full slot while a side-docked inspector is present" + ) + } + + func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let initialInspectorWidth: CGFloat = 110 + let inspectorContainer = NSView( + frame: NSRect( + x: slot.bounds.width - initialInspectorWidth, + y: 0, + width: initialInspectorWidth, + height: slot.bounds.height + ) + ) + inspectorContainer.autoresizingMask = [.minXMargin, .height] + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(inspectorContainer) + + webView.frame = NSRect( + x: 0, + y: 0, + width: slot.bounds.width - initialInspectorWidth, + height: slot.bounds.height + ) + webView.autoresizingMask = [.width, .height] + slot.layoutSubtreeIfNeeded() + + anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible") + XCTAssertEqual( + webView.frame.maxX, + inspectorContainer.frame.minX, + accuracy: 0.5, + "Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector" + ) + XCTAssertLessThan( + webView.frame.width, + slot.bounds.width, + "Side-docked inspector should still own part of the slot after pane resize" + ) + } + + func testPortalAnchorResizeDoesNotForceHostedWebViewPresentationRefresh() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let initialDisplayCount = webView.displayIfNeededCount + let initialReattachCount = webView.reattachRenderingStateCount + anchor.frame = NSRect(x: 52, y: 30, width: 248, height: 178) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertFalse(slot.isHidden, "Anchor resize should keep the portal-hosted browser visible") + XCTAssertEqual(slot.frame.origin.x, 52, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, 30, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, 248, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, 178, accuracy: 0.5) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + initialDisplayCount, + "Pure anchor geometry updates should still repaint the hosted browser" + ) + XCTAssertEqual( + webView.reattachRenderingStateCount, + initialReattachCount, + "Pure anchor geometry updates should not trigger the WebKit reattach path" + ) + } + + func testExternalSplitResizeDoesNotForceHostedWebViewPresentationRefresh() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 360), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let splitView = NSSplitView(frame: contentView.bounds) + splitView.autoresizingMask = [.width, .height] + splitView.isVertical = true + + let leadingPane = NSView( + frame: NSRect(x: 0, y: 0, width: 220, height: contentView.bounds.height) + ) + leadingPane.autoresizingMask = [.height] + let trailingPane = NSView( + frame: NSRect( + x: 221, + y: 0, + width: contentView.bounds.width - 221, + height: contentView.bounds.height + ) + ) + trailingPane.autoresizingMask = [.width, .height] + splitView.addSubview(leadingPane) + splitView.addSubview(trailingPane) + contentView.addSubview(splitView) + splitView.adjustSubviews() + + let anchor = NSView(frame: trailingPane.bounds.insetBy(dx: 12, dy: 12)) + anchor.autoresizingMask = [.width, .height] + trailingPane.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let initialDisplayCount = webView.displayIfNeededCount + let initialReattachCount = webView.reattachRenderingStateCount + let initialWidth = slot.frame.width + + splitView.setPosition(280, ofDividerAt: 0) + contentView.layoutSubtreeIfNeeded() + NotificationCenter.default.post(name: NSSplitView.didResizeSubviewsNotification, object: splitView) + advanceAnimations() + + XCTAssertFalse(slot.isHidden, "App split resize should keep the browser slot visible") + XCTAssertLessThan( + slot.frame.width, + initialWidth, + "Moving the app split divider should shrink the hosted browser slot" + ) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + initialDisplayCount, + "External split resize should still repaint the hosted browser" + ) + XCTAssertEqual( + webView.reattachRenderingStateCount, + initialReattachCount, + "External split resize should not trigger the WebKit reattach path" + ) + } + + func testPortalSyncRepairsBottomDockedInspectorOverflowedPageFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let inspectorHeight: CGFloat = 84 + let inspectorContainer = NSView( + frame: NSRect(x: 0, y: 0, width: slot.bounds.width, height: inspectorHeight) + ) + inspectorContainer.autoresizingMask = [.width] + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(inspectorContainer) + + webView.frame = NSRect( + x: 0, + y: inspectorHeight, + width: slot.bounds.width, + height: slot.bounds.height + ) + webView.autoresizingMask = [.width, .height] + slot.layoutSubtreeIfNeeded() + + portal.synchronizeWebViewForAnchor(anchor) + + XCTAssertFalse(slot.isHidden, "Portal sync should keep the hosted browser visible") + XCTAssertEqual( + webView.frame.minY, + inspectorHeight, + accuracy: 0.5, + "Portal sync should keep the page viewport below a bottom-docked inspector instead of shifting the page upward" + ) + XCTAssertEqual( + webView.frame.height, + slot.bounds.height - inspectorHeight, + accuracy: 0.5, + "Portal sync should shrink the page viewport to the space above a bottom-docked inspector" + ) + XCTAssertEqual( + webView.frame.maxY, + slot.bounds.maxY, + accuracy: 0.5, + "The repaired page viewport should stay flush with the top edge of the slot" + ) + } + + func testHidingBrowserSlotYieldsOwnedInspectorFirstResponder() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let slot = WindowBrowserSlotView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(slot) + + let inspectorContainer = NSView(frame: slot.bounds) + inspectorContainer.autoresizingMask = [.width, .height] + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + window.makeFirstResponder(inspectorView), + "Precondition failed: inspector probe should become first responder" + ) + XCTAssertTrue(window.firstResponder === inspectorView) + + slot.isHidden = true + + XCTAssertFalse( + window.firstResponder === inspectorView, + "Hiding a browser slot should yield any owned inspector responder before it goes off-screen" + ) + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse( + firstResponderView === slot || firstResponderView.isDescendant(of: slot), + "Hiding a browser slot should not leave first responder inside the hidden slot" + ) + } + } + + func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden") + + let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame) + contentView.addSubview(localInlineSlot) + + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72) + ) + inspectorView.autoresizingMask = [.width] + localInlineSlot.addSubview(inspectorView) + + localInlineSlot.addSubview(webView) + webView.frame = NSRect( + x: 0, + y: inspectorView.frame.maxY, + width: localInlineSlot.bounds.width, + height: localInlineSlot.bounds.height - inspectorView.frame.height + ) + localInlineSlot.layoutSubtreeIfNeeded() + + anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) + localInlineSlot.frame = anchor.frame + contentView.layoutSubtreeIfNeeded() + localInlineSlot.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + XCTAssertTrue( + webView.superview === localInlineSlot, + "Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize" + ) + XCTAssertTrue( + inspectorView.superview === localInlineSlot, + "Hidden portal sync should leave local DevTools companion views in the local inline host" + ) + XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting") + } + func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), @@ -3549,6 +12821,221 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync") } + func testPortalDropZoneOverlayPersistsAcrossVisibilityChanges() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView, + let overlay = dropZoneOverlay(in: slot, excluding: webView) else { + XCTFail("Expected browser slot overlay") + return + } + + XCTAssertTrue(overlay.isHidden, "Overlay should start hidden without an active drop zone") + + portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right) + slot.layoutSubtreeIfNeeded() + XCTAssertFalse(overlay.isHidden) + XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view") + XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5) + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + XCTAssertTrue(overlay.isHidden, "Invisible browser entries should hide the overlay") + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + XCTAssertFalse(overlay.isHidden, "Restoring visibility should restore the active drop-zone overlay") + } + + func testPortalRevealRefreshesHostedWebViewWithoutFrameDelta() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + let initialDisplayCount = webView.displayIfNeededCount + let initialReattachCount = webView.reattachRenderingStateCount + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + let hiddenDisplayCount = webView.displayIfNeededCount + let hiddenReattachCount = webView.reattachRenderingStateCount + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertGreaterThanOrEqual(hiddenDisplayCount, initialDisplayCount) + XCTAssertEqual( + hiddenReattachCount, + initialReattachCount, + "Hiding a portal-hosted browser should not itself trigger the WebKit reattach path" + ) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + hiddenDisplayCount, + "Revealing an existing portal-hosted browser should refresh WebKit presentation immediately" + ) + XCTAssertGreaterThan( + webView.reattachRenderingStateCount, + hiddenReattachCount, + "Revealing an existing portal-hosted browser should trigger the WebKit reattach path" + ) + } + + func testVisiblePortalEntryHidesWithoutDetachingDuringTransientAnchorRemovalUntilRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let anchor1 = NSView(frame: anchorFrame) + contentView.addSubview(anchor1) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor1, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor1) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + anchor1.removeFromSuperview() + portal.synchronizeWebViewForAnchor(anchor1) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Visible browser entries should not detach during transient anchor removal") + XCTAssertTrue( + slot.isHidden, + "Transient anchor churn should hide the stale browser slot instead of rendering in the wrong pane" + ) + XCTAssertEqual(portal.debugEntryCount(), 1) + + let displayCountBeforeRebind = webView.displayIfNeededCount + let anchor2 = NSView(frame: anchorFrame) + contentView.addSubview(anchor2) + portal.bind(webView: webView, to: anchor2, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor2) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Rebinding after transient anchor removal should reuse the existing portal slot") + XCTAssertFalse(slot.isHidden) + XCTAssertEqual(portal.debugEntryCount(), 1) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + displayCountBeforeRebind, + "Anchor rebinds should refresh hosted browser presentation even when geometry is unchanged" + ) + } + + func testVisiblePortalEntryStaysVisibleDuringOffWindowAnchorReparentUntilRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let anchor = NSView(frame: anchorFrame) + contentView.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let offWindowContainer = NSView(frame: anchorFrame) + anchor.removeFromSuperview() + offWindowContainer.addSubview(anchor) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertTrue( + webView.superview === slot, + "Off-window anchor reparent should preserve the hosted browser slot during drag churn" + ) + XCTAssertFalse( + slot.isHidden, + "Off-window anchor reparent should keep the visible browser portal alive until the anchor returns" + ) + XCTAssertEqual(portal.debugEntryCount(), 1) + + contentView.addSubview(anchor) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Rebinding after off-window reparent should reuse the existing portal slot") + XCTAssertFalse(slot.isHidden) + XCTAssertEqual(portal.debugEntryCount(), 1) + } + func testRegistryDetachRemovesPortalHostedWebView() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -3573,6 +13060,262 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { BrowserWindowPortalRegistry.detach(webView: webView) XCTAssertNil(webView.superview) } + + func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + contentView.addSubview(anchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + XCTAssertFalse(slot.isHidden) + + BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest") + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment") + XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot") + } + + func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let oldAnchor = NSView(frame: anchorFrame) + contentView.addSubview(oldAnchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: oldAnchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(oldAnchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(oldAnchor) + advanceAnimations() + XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount") + + oldAnchor.removeFromSuperview() + portal.synchronizeWebViewForAnchor(oldAnchor) + advanceAnimations() + + XCTAssertTrue( + webView.superview === slot, + "Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted" + ) + XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound") + XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive") + + let displayCountBeforeRebind = webView.displayIfNeededCount + let newAnchor = NSView(frame: anchorFrame) + contentView.addSubview(newAnchor) + portal.bind(webView: webView, to: newAnchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(newAnchor) + advanceAnimations() + + XCTAssertTrue( + webView.superview === slot, + "Selecting the workspace again should reuse the existing hidden browser portal slot" + ) + XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot") + XCTAssertEqual(portal.debugEntryCount(), 1) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + displayCountBeforeRebind, + "Workspace rebind should refresh the preserved browser without recreating its portal slot" + ) + } +} + +@MainActor +final class FileDropOverlayViewTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + + func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + realizeWindowLayout(window) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + + let overlay = FileDropOverlayView(frame: container.bounds) + overlay.autoresizingMask = [.width, .height] + container.addSubview(overlay, positioned: .above, relativeTo: nil) + + let point = anchor.convert( + NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY), + to: nil + ) + XCTAssertTrue( + overlay.webViewUnderPoint(point) === webView, + "File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView" + ) + } +} + +@MainActor +final class MarkdownPanelPointerObserverViewTests: XCTestCase { + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + return window + } + + private func makeMouseEvent( + type: NSEvent.EventType, + location: NSPoint, + window: NSWindow, + eventNumber: Int = 1 + ) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: eventNumber, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Expected to create mouse event") + } + return event + } + + func testObserverTriggersFocusForVisibleLeftClickInsideBounds() { + let window = makeWindow() + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) + overlay.autoresizingMask = [.width, .height] + let focusExpectation = expectation(description: "observer forwards focus callback") + var pointerDownCount = 0 + overlay.onPointerDown = { + pointerDownCount += 1 + focusExpectation.fulfill() + } + contentView.addSubview(overlay) + + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window) + ) + wait(for: [focusExpectation], timeout: 1.0) + + XCTAssertEqual(pointerDownCount, 1) + } + + func testObserverIgnoresOutsideOrForeignWindowClicks() { + let window = makeWindow() + defer { window.orderOut(nil) } + let otherWindow = makeWindow() + defer { otherWindow.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) + overlay.autoresizingMask = [.width, .height] + let noFocusExpectation = expectation(description: "observer ignores invalid clicks") + noFocusExpectation.isInverted = true + var pointerDownCount = 0 + overlay.onPointerDown = { + pointerDownCount += 1 + noFocusExpectation.fulfill() + } + contentView.addSubview(overlay) + + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window) + ) + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2) + ) + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3) + ) + wait(for: [noFocusExpectation], timeout: 0.1) + + XCTAssertEqual(pointerDownCount, 0) + } + + func testObserverDoesNotParticipateInHitTesting() { + let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30))) + } } final class BrowserLinkOpenSettingsTests: XCTestCase { @@ -3604,6 +13347,96 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) } + + func testSidebarPullRequestLinksDefaultToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + + func testSidebarPullRequestLinksPreferenceUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionDefaultsToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testSettingsInitialOpenCommandInterceptionValueFallsBackToLegacyLinkToggleWhenUnset() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) + } + + func testExternalOpenPatternsDefaultToEmpty() { + XCTAssertTrue(BrowserLinkOpenSettings.externalOpenPatterns(defaults: defaults).isEmpty) + } + + func testExternalOpenLiteralPatternMatchesCaseInsensitively() { + defaults.set("openai.com/account/usage", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://platform.OPENAI.com/account/usage", + defaults: defaults + ) + ) + } + + func testExternalOpenRegexPatternMatchesCaseInsensitively() { + defaults.set( + "re:^https?://[^/]*\\.example\\.com/(billing|usage)", + forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey + ) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://FOO.example.com/BILLING", + defaults: defaults + ) + ) + } + + func testExternalOpenRegexPatternSupportsDigitCharacterClass() { + defaults.set( + "re:^https://example\\.com/usage/\\d+$", + forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey + ) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://example.com/usage/42", + defaults: defaults + ) + ) + } + + func testExternalOpenPatternsIgnoreInvalidRegexEntries() { + defaults.set("re:(\nexample.com", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://example.com/path", + defaults: defaults + ) + ) + } } final class TerminalOpenURLTargetResolutionTests: XCTestCase { @@ -3676,6 +13509,92 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } } +final class BrowserNavigableURLResolutionTests: XCTestCase { + func testResolvesFileSchemeAsNavigableURL() throws { + let resolved = try XCTUnwrap(resolveBrowserNavigableURL("file:///tmp/cmux-local-test.html")) + XCTAssertTrue(resolved.isFileURL) + XCTAssertEqual(resolved.path, "/tmp/cmux-local-test.html") + } + + func testRejectsNonWebNonFileScheme() { + XCTAssertNil(resolveBrowserNavigableURL("mailto:test@example.com")) + XCTAssertNil(resolveBrowserNavigableURL("ftp://example.com/file.html")) + } + + func testRejectsHostOnlyFileURL() { + XCTAssertNil(resolveBrowserNavigableURL("file://example.html")) + } +} + +final class BrowserReadAccessURLTests: XCTestCase { + func testUsesParentDirectoryForFileURL() throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true) + let file = dir.appendingPathComponent("sample.html") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try "<html></html>".write(to: file, atomically: true, encoding: .utf8) + + let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: file)) + XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL) + } + + func testUsesDirectoryURLWhenTargetIsDirectory() throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: dir)) + XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL) + } + + func testUsesParentDirectoryWhenFileDoesNotExist() throws { + let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).html") + let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: missing)) + XCTAssertEqual(readAccessURL.standardizedFileURL, missing.deletingLastPathComponent().standardizedFileURL) + } + + func testReturnsNilForHostOnlyFileURL() throws { + let hostOnly = try XCTUnwrap(URL(string: "file://example.html")) + XCTAssertNil(browserReadAccessURL(forLocalFileURL: hostOnly)) + } +} + +final class BrowserExternalNavigationSchemeTests: XCTestCase { + func testCustomAppSchemesOpenExternally() throws { + let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc")) + let slack = try XCTUnwrap(URL(string: "slack://open")) + let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join")) + let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com")) + + XCTAssertTrue(browserShouldOpenURLExternally(discord)) + XCTAssertTrue(browserShouldOpenURLExternally(slack)) + XCTAssertTrue(browserShouldOpenURLExternally(zoom)) + XCTAssertTrue(browserShouldOpenURLExternally(mailto)) + } + + func testEmbeddedBrowserSchemesStayInWebView() throws { + let https = try XCTUnwrap(URL(string: "https://example.com")) + let http = try XCTUnwrap(URL(string: "http://example.com")) + let about = try XCTUnwrap(URL(string: "about:blank")) + let data = try XCTUnwrap(URL(string: "data:text/plain,hello")) + let file = try XCTUnwrap(URL(string: "file:///tmp/cmux-local-test.html")) + let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000")) + let javascript = try XCTUnwrap(URL(string: "javascript:void(0)")) + let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page")) + + XCTAssertFalse(browserShouldOpenURLExternally(https)) + XCTAssertFalse(browserShouldOpenURLExternally(http)) + XCTAssertFalse(browserShouldOpenURLExternally(about)) + XCTAssertFalse(browserShouldOpenURLExternally(data)) + XCTAssertFalse(browserShouldOpenURLExternally(file)) + XCTAssertFalse(browserShouldOpenURLExternally(blob)) + XCTAssertFalse(browserShouldOpenURLExternally(javascript)) + XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal)) + } +} + final class BrowserHostWhitelistTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults! @@ -3783,7 +13702,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { key: "agent", value: "idle", icon: "bolt", - color: "#ffffff" + color: "#ffffff", + url: nil, + priority: 0, + format: .plain ) ) } @@ -3802,7 +13724,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { key: "agent", value: "running", icon: "bolt", - color: "#ffffff" + color: "#ffffff", + url: nil, + priority: 0, + format: .plain ) ) } @@ -3928,3 +13853,188 @@ final class TerminalControllerSocketTextChunkTests: XCTestCase { ) } } + +final class BrowserOmnibarFocusPolicyTests: XCTestCase { + func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() { + XCTAssertTrue( + browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: true, + nextResponderIsOtherTextField: false + ) + ) + } + + func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() { + XCTAssertFalse( + browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: true, + nextResponderIsOtherTextField: true + ) + ) + } + + func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() { + XCTAssertFalse( + browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: false, + nextResponderIsOtherTextField: false + ) + ) + } +} + +final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { + func testImmediateStateUpdateAllowedWhenHostNotInWindow() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: true, + isBoundToCurrentHost: true + ) + ) + } + + func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() { + XCTAssertFalse( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: false, + isBoundToCurrentHost: false + ) + ) + } +} + +final class TerminalControllerSocketListenerHealthTests: XCTestCase { + private func makeTempSocketPath() -> String { + "/tmp/cmux-socket-health-\(UUID().uuidString).sock" + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"] + ) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strcpy(pathBuf, ptr) + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) + } + } + guard bindResult == 0 else { + let code = Int(errno) + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: code, + userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"] + ) + } + + guard Darwin.listen(fd, 1) == 0 else { + let code = Int(errno) + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: code, + userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"] + ) + } + + return fd + } + + @MainActor + func testSocketListenerHealthRecognizesSocketPath() throws { + let path = makeTempSocketPath() + let fd = try bindUnixSocket(at: path) + defer { + Darwin.close(fd) + unlink(path) + } + + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path) + XCTAssertTrue(health.socketPathExists) + XCTAssertFalse(health.isHealthy) + } + + @MainActor + func testSocketListenerHealthRejectsRegularFile() throws { + let path = makeTempSocketPath() + let url = URL(fileURLWithPath: path) + try "not-a-socket".write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path) + XCTAssertFalse(health.socketPathExists) + XCTAssertFalse(health.isHealthy) + } + + func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() { + let health = TerminalController.SocketListenerHealth( + isRunning: true, + acceptLoopAlive: true, + socketPathMatches: true, + socketPathExists: true, + socketProbePerformed: true, + socketConnectable: true, + socketConnectErrno: nil + ) + XCTAssertTrue(health.isHealthy) + XCTAssertTrue(health.failureSignals.isEmpty) + XCTAssertTrue(health.socketProbePerformed) + XCTAssertEqual(health.socketConnectable, true) + XCTAssertNil(health.socketConnectErrno) + } + + func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { + let health = TerminalController.SocketListenerHealth( + isRunning: false, + acceptLoopAlive: false, + socketPathMatches: false, + socketPathExists: false, + socketProbePerformed: false, + socketConnectable: nil, + socketConnectErrno: nil + ) + XCTAssertFalse(health.isHealthy) + XCTAssertFalse(health.socketProbePerformed) + XCTAssertNil(health.socketConnectable) + XCTAssertNil(health.socketConnectErrno) + XCTAssertEqual( + health.failureSignals, + ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] + ) + } +} diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift new file mode 100644 index 00000000..fd9ada43 --- /dev/null +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -0,0 +1,621 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class CommandPaletteSearchEngineTests: XCTestCase { + private struct FixtureEntry { + let id: String + let rank: Int + let title: String + let searchableTexts: [String] + } + + private struct FixtureResult: Equatable { + let id: String + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set<Int> + } + + private func makeCommandEntries(count: Int) -> [FixtureEntry] { + (0..<count).map { index in + let title: String + let subtitle: String + let keywords: [String] + + switch index % 8 { + case 0: + title = "Rename Workspace \(index)" + subtitle = "Workspace" + keywords = ["rename", "workspace", "title", "project", "switch"] + case 1: + title = "Rename Tab \(index)" + subtitle = "Tab" + keywords = ["rename", "tab", "surface", "title"] + case 2: + title = "Open Current Directory in IDE \(index)" + subtitle = "Terminal" + keywords = ["open", "directory", "cwd", "ide", "vscode"] + case 3: + title = "Toggle Sidebar \(index)" + subtitle = "Layout" + keywords = ["toggle", "sidebar", "layout", "panel"] + case 4: + title = "Apply Update If Available \(index)" + subtitle = "Global" + keywords = ["apply", "update", "install", "upgrade"] + case 5: + title = "Restart CLI Listener \(index)" + subtitle = "Global" + keywords = ["restart", "cli", "listener", "socket", "cmux"] + case 6: + title = "Show Notifications \(index)" + subtitle = "Notifications" + keywords = ["notifications", "inbox", "unread", "alerts"] + default: + title = "Split Browser Right \(index)" + subtitle = "Layout" + keywords = ["split", "browser", "right", "layout", "web"] + } + + return FixtureEntry( + id: "command.\(index)", + rank: index, + title: title, + searchableTexts: [title, subtitle] + keywords + ) + } + } + + private func makeSwitcherEntries(count: Int) -> [FixtureEntry] { + (0..<count).map { index in + let title = "Workspace \(index) Phoenix" + let keywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace", "switch", "go", title], + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feature-\(index)-rename-tab"], + branches: ["feature/rename-tab-\(index)"], + ports: [3000 + (index % 20), 9200 + (index % 5)] + ), + detail: .workspace + ) + return FixtureEntry( + id: "workspace.\(index)", + rank: index, + title: title, + searchableTexts: [title, "Workspace"] + keywords + ) + } + } + + private func makeFinderCommandEntries() -> [FixtureEntry] { + [ + FixtureEntry( + id: "command.find", + rank: 0, + title: "Find...", + searchableTexts: ["Find...", "Search", "find", "search"] + ), + FixtureEntry( + id: "command.finder", + rank: 1, + title: "Open Current Directory in Finder", + searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] + ), + FixtureEntry( + id: "command.filter", + rank: 2, + title: "Filter Sidebar Items", + searchableTexts: ["Filter Sidebar Items", "Sidebar", "filter", "sidebar", "items"] + ), + ] + } + + private func optimizedResults( + entries: [FixtureEntry], + query: String + ) -> [FixtureResult] { + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + + return CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + .map { + FixtureResult( + id: $0.payload, + rank: $0.rank, + title: $0.title, + score: $0.score, + titleMatchIndices: $0.titleMatchIndices + ) + } + } + + private func legacyResults( + entries: [FixtureEntry], + query: String + ) -> [FixtureResult] { + let queryIsEmpty = query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let results: [FixtureResult] = queryIsEmpty + ? entries.map { entry in + FixtureResult(id: entry.id, rank: entry.rank, title: entry.title, score: 0, titleMatchIndices: []) + } + : entries.compactMap { entry in + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + query: query, + candidates: entry.searchableTexts + ) else { + return nil + } + return FixtureResult( + id: entry.id, + rank: entry.rank, + title: entry.title, + score: fuzzyScore, + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: query, + candidate: entry.title + ) + ) + } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + private func benchmarkElapsedMs(operation: () -> Void) -> Double { + let start = DispatchTime.now().uptimeNanoseconds + operation() + let elapsed = DispatchTime.now().uptimeNanoseconds - start + return Double(elapsed) / 1_000_000 + } + + private func repeatedQueries(_ baseQueries: [String], repetitions: Int) -> [String] { + Array(repeating: baseQueries, count: repetitions).flatMap { $0 } + } + + func testOptimizedSearchMatchesLegacyPipeline() { + let commandEntries = makeCommandEntries(count: 96) + let switcherEntries = makeSwitcherEntries(count: 64) + let queries = [ + "rename", + "rename tab", + "workspace", + "feature-12", + "3004", + "toggle side", + "open dir", + "phoenix", + "apply update", + ] + + for query in queries { + XCTAssertEqual( + optimizedResults(entries: commandEntries, query: query), + legacyResults(entries: commandEntries, query: query), + "Command corpus mismatch for query \(query)" + ) + XCTAssertEqual( + optimizedResults(entries: switcherEntries, query: query), + legacyResults(entries: switcherEntries, query: query), + "Switcher corpus mismatch for query \(query)" + ) + } + } + + func testSearchCancellationReturnsNoResults() { + let entries = makeCommandEntries(count: 512) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + var cancellationChecks = 0 + + let results = CommandPaletteSearchEngine.search( + entries: corpus, + query: "rename" + ) { _, _ in + 0 + } shouldCancel: { + cancellationChecks += 1 + return cancellationChecks >= 4 + } + + XCTAssertTrue(results.isEmpty) + XCTAssertGreaterThanOrEqual(cancellationChecks, 4) + } + + func testCommandPreviewSearchUsesFullCommandCorpus() { + let entries = [ + FixtureEntry( + id: "command.find", + rank: 0, + title: "Find...", + searchableTexts: ["Find...", "Search", "find", "search"] + ), + FixtureEntry( + id: "command.finder", + rank: 1, + title: "Open Current Directory in Finder", + searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] + ), + ] + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let corpusByID = Dictionary(uniqueKeysWithValues: corpus.map { ($0.payload, $0) }) + + let previewCommandIDs = ContentView.commandPaletteCommandPreviewMatchCommandIDsForTests( + searchCorpus: corpus, + candidateCommandIDs: ["command.find"], + searchCorpusByID: corpusByID, + query: "finde", + resultLimit: 48 + ) + + XCTAssertEqual(previewCommandIDs.first, "command.finder") + } + + func testSearchMatchesSingleOmittedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "findr").first?.id, + "command.finder" + ) + } + + func testSearchMatchesSingleInsertedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "findder").first?.id, + "command.finder" + ) + } + + func testSearchMatchesSingleSubstitutedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "fander").first?.id, + "command.finder" + ) + } + + func testSearchMatchesSingleTransposedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "fidner").first?.id, + "command.finder" + ) + } + + func testSearchRejectsMultipleEditsInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertNotEqual( + optimizedResults(entries: entries, query: "fadnr").first?.id, + "command.finder" + ) + } + + func testResolvedSelectionIndexPrefersAnchoredCommand() { + let resultIDs = ["command.0", "command.1", "command.2"] + + XCTAssertEqual( + ContentView.commandPaletteResolvedSelectionIndex( + preferredCommandID: "command.2", + fallbackSelectedIndex: 0, + resultIDs: resultIDs + ), + 2 + ) + XCTAssertEqual( + ContentView.commandPaletteResolvedSelectionIndex( + preferredCommandID: "missing", + fallbackSelectedIndex: 9, + resultIDs: resultIDs + ), + 2 + ) + XCTAssertEqual( + ContentView.commandPaletteResolvedSelectionIndex( + preferredCommandID: nil, + fallbackSelectedIndex: 1, + resultIDs: [] + ), + 0 + ) + } + + func testResolvedPendingActivationPreservesSubmitAndClickSemantics() { + let resultIDs = ["command.0", "command.1", "command.2"] + + XCTAssertEqual( + ContentView.commandPaletteResolvedPendingActivation( + .selected(requestID: 41, fallbackSelectedIndex: 0, preferredCommandID: "command.2"), + requestID: 41, + resultIDs: resultIDs + ), + .selected(index: 2) + ) + XCTAssertEqual( + ContentView.commandPaletteResolvedPendingActivation( + .command(requestID: 41, commandID: "command.1"), + requestID: 41, + resultIDs: resultIDs + ), + .command(commandID: "command.1") + ) + XCTAssertNil( + ContentView.commandPaletteResolvedPendingActivation( + .command(requestID: 41, commandID: "missing"), + requestID: 41, + resultIDs: resultIDs + ) + ) + XCTAssertNil( + ContentView.commandPaletteResolvedPendingActivation( + .selected(requestID: 40, fallbackSelectedIndex: 0, preferredCommandID: nil), + requestID: 41, + resultIDs: resultIDs + ) + ) + } + + func testSelectionAnchorTracksVisiblePendingSelection() { + let resultIDs = ["command.0", "command.1", "command.2"] + let visibleAnchor = ContentView.commandPaletteSelectionAnchorCommandID( + selectedIndex: 2, + resultIDs: resultIDs + ) + + XCTAssertEqual( + ContentView.commandPaletteResolvedPendingActivation( + .selected( + requestID: 41, + fallbackSelectedIndex: 0, + preferredCommandID: visibleAnchor + ), + requestID: 41, + resultIDs: resultIDs + ), + .selected(index: 2) + ) + } + + func testPreviewCandidateCommandIDsAreBounded() { + let resultIDs = (0..<500).map { "command.\($0)" } + + let previewCandidateIDs = ContentView.commandPalettePreviewCandidateCommandIDs( + resultIDs: resultIDs, + limit: 192 + ) + + XCTAssertEqual(previewCandidateIDs.count, 192) + XCTAssertEqual(previewCandidateIDs.first, "command.0") + XCTAssertEqual(previewCandidateIDs.last, "command.191") + } + + func testSynchronousSeedRunsOnlyWhenScopeChanges() { + XCTAssertTrue( + ContentView.commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: false + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: true + ) + ) + } + + func testCommandContextFingerprintTracksExactContextValues() { + let base = ContentView.commandPaletteContextFingerprint( + boolValues: [ + "workspace.hasPullRequests": true, + "panel.hasUnread": false, + "panel.isTerminal": true, + ], + stringValues: [ + "workspace.name": "Alpha", + "panel.name": "Main", + ] + ) + let unreadChanged = ContentView.commandPaletteContextFingerprint( + boolValues: [ + "workspace.hasPullRequests": true, + "panel.hasUnread": true, + "panel.isTerminal": true, + ], + stringValues: [ + "workspace.name": "Alpha", + "panel.name": "Main", + ] + ) + let renamed = ContentView.commandPaletteContextFingerprint( + boolValues: [ + "workspace.hasPullRequests": true, + "panel.hasUnread": false, + "panel.isTerminal": true, + ], + stringValues: [ + "workspace.name": "Alpha", + "panel.name": "Logs", + ] + ) + + XCTAssertNotEqual(base, unreadChanged) + XCTAssertNotEqual(base, renamed) + } + + func testSwitcherFingerprintTracksMetadataValuesAtSameCardinality() { + let windowID = UUID() + let workspaceID = UUID() + let base = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: "Window 2", + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Alpha", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm"], + branches: ["feature/search-speed"], + ports: [3000] + ) + ) + ] + ) + ] + ) + let changedMetadata = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: "Window 2", + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Alpha", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/other"], + branches: ["feature/search-speed"], + ports: [4000] + ) + ) + ] + ) + ] + ) + let changedDisplayName = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: "Window 2", + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Beta", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm"], + branches: ["feature/search-speed"], + ports: [3000] + ) + ) + ] + ) + ] + ) + + XCTAssertNotEqual(base, changedMetadata) + XCTAssertNotEqual(base, changedDisplayName) + } + + func testCommandSearchBenchmarkBeatsLegacyPipeline() { + let entries = makeCommandEntries(count: 900) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let queries = repeatedQueries( + ["rename", "rename tab", "open dir", "toggle side", "apply update", "notif", "split right", "cmux"], + repetitions: 12 + ) + + for query in queries.prefix(8) { + _ = legacyResults(entries: entries, query: query) + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + + let legacyMs = benchmarkElapsedMs { + for query in queries { + _ = legacyResults(entries: entries, query: query) + } + } + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + } + + print(String(format: "BENCH cmd+shift+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) + XCTAssertLessThan( + optimizedMs, + legacyMs * 1.25, + "Optimized command search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)" + ) + } + + func testSwitcherSearchBenchmarkBeatsLegacyPipeline() { + let entries = makeSwitcherEntries(count: 400) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let queries = repeatedQueries( + ["workspace 12", "phoenix", "feature-18", "rename-tab", "3007", "9202", "switch", "worktrees"], + repetitions: 12 + ) + + for query in queries.prefix(8) { + _ = legacyResults(entries: entries, query: query) + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + + let legacyMs = benchmarkElapsedMs { + for query in queries { + _ = legacyResults(entries: entries, query: query) + } + } + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + } + + print(String(format: "BENCH cmd+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) + XCTAssertLessThan( + optimizedMs, + legacyMs * 1.25, + "Optimized switcher search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)" + ) + } +} diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 994ccf25..fdc316b7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -7,6 +7,38 @@ import AppKit @testable import cmux #endif +final class SidebarPathFormatterTests: XCTestCase { + func testShortenedPathReplacesExactHomeDirectory() { + XCTAssertEqual( + SidebarPathFormatter.shortenedPath( + "/Users/example", + homeDirectoryPath: "/Users/example" + ), + "~" + ) + } + + func testShortenedPathReplacesHomeDirectoryPrefix() { + XCTAssertEqual( + SidebarPathFormatter.shortenedPath( + "/Users/example/projects/cmux", + homeDirectoryPath: "/Users/example" + ), + "~/projects/cmux" + ) + } + + func testShortenedPathLeavesExternalPathUnchanged() { + XCTAssertEqual( + SidebarPathFormatter.shortenedPath( + "/tmp/cmux", + homeDirectoryPath: "/Users/example" + ), + "/tmp/cmux" + ) + } +} + final class GhosttyConfigTests: XCTestCase { private struct RGB: Equatable { let red: Int @@ -102,6 +134,12 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(rgb255(darkConfig.backgroundColor), RGB(red: 0, green: 43, blue: 54)) } + func testParseBackgroundOpacityReadsConfigValue() { + var config = GhosttyConfig() + config.parse("background-opacity = 0.42") + XCTAssertEqual(config.backgroundOpacity, 0.42, accuracy: 0.0001) + } + func testLoadThemeResolvesBuiltinAliasFromGhosttyResourcesDir() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-ghostty-themes-\(UUID().uuidString)") @@ -126,6 +164,64 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227)) } + func testLoadCachesPerColorScheme() { + GhosttyConfig.invalidateLoadCache() + defer { GhosttyConfig.invalidateLoadCache() } + + var loadCount = 0 + let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { scheme in + loadCount += 1 + var config = GhosttyConfig() + config.fontFamily = "\(scheme)-\(loadCount)" + return config + } + + let lightFirst = GhosttyConfig.load( + preferredColorScheme: .light, + loadFromDisk: loadFromDisk + ) + let lightSecond = GhosttyConfig.load( + preferredColorScheme: .light, + loadFromDisk: loadFromDisk + ) + let darkFirst = GhosttyConfig.load( + preferredColorScheme: .dark, + loadFromDisk: loadFromDisk + ) + + XCTAssertEqual(loadCount, 2) + XCTAssertEqual(lightFirst.fontFamily, "light-1") + XCTAssertEqual(lightSecond.fontFamily, "light-1") + XCTAssertEqual(darkFirst.fontFamily, "dark-2") + } + + func testLoadCacheInvalidationForcesReload() { + GhosttyConfig.invalidateLoadCache() + defer { GhosttyConfig.invalidateLoadCache() } + + var loadCount = 0 + let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { _ in + loadCount += 1 + var config = GhosttyConfig() + config.fontFamily = "reload-\(loadCount)" + return config + } + + let first = GhosttyConfig.load( + preferredColorScheme: .dark, + loadFromDisk: loadFromDisk + ) + GhosttyConfig.invalidateLoadCache() + let second = GhosttyConfig.load( + preferredColorScheme: .dark, + loadFromDisk: loadFromDisk + ) + + XCTAssertEqual(loadCount, 2) + XCTAssertEqual(first.fontFamily, "reload-1") + XCTAssertEqual(second.fontFamily, "reload-2") + } + func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() { XCTAssertTrue( GhosttyApp.shouldLoadLegacyGhosttyConfig( @@ -162,6 +258,183 @@ final class GhosttyConfigTests: XCTestCase { ) } + func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() { + XCTAssertTrue( + GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( + currentBundleIdentifier: "com.cmuxterm.app.debug", + currentConfigFileSize: nil, + currentLegacyConfigFileSize: nil, + releaseConfigFileSize: 128, + releaseLegacyConfigFileSize: nil + ) + ) + } + + func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() { + XCTAssertFalse( + GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( + currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829", + currentConfigFileSize: nil, + currentLegacyConfigFileSize: 64, + releaseConfigFileSize: 128, + releaseLegacyConfigFileSize: nil + ) + ) + } + + func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() { + XCTAssertFalse( + GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( + currentBundleIdentifier: "com.cmuxterm.app", + currentConfigFileSize: nil, + currentLegacyConfigFileSize: nil, + releaseConfigFileSize: 128, + releaseLegacyConfigFileSize: nil + ) + ) + + XCTAssertFalse( + GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( + currentBundleIdentifier: "com.cmuxterm.app.debug", + currentConfigFileSize: nil, + currentLegacyConfigFileSize: nil, + releaseConfigFileSize: nil, + releaseLegacyConfigFileSize: 0 + ) + ) + } + + func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() { + XCTAssertTrue( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .unscoped, + incomingScope: .app + ) + ) + XCTAssertTrue( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .app, + incomingScope: .surface + ) + ) + XCTAssertTrue( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .surface, + incomingScope: .surface + ) + ) + XCTAssertFalse( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .surface, + incomingScope: .app + ) + ) + XCTAssertFalse( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .surface, + incomingScope: .unscoped + ) + ) + } + + func testAppearanceChangeReloadsWhenColorSchemeChanges() { + XCTAssertTrue( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: .dark, + currentColorScheme: .light + ) + ) + XCTAssertTrue( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: nil, + currentColorScheme: .dark + ) + ) + } + + func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() { + XCTAssertFalse( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: .light, + currentColorScheme: .light + ) + ) + XCTAssertFalse( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: .dark, + currentColorScheme: .dark + ) + ) + } + + func testScrollLagCaptureRequiresSustainedLag() { + XCTAssertFalse( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 4, + averageMs: 18, + maxMs: 85, + thresholdMs: 40, + nowUptime: 1000, + lastReportedUptime: nil + ) + ) + XCTAssertFalse( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 10, + averageMs: 6, + maxMs: 85, + thresholdMs: 40, + nowUptime: 1000, + lastReportedUptime: nil + ) + ) + XCTAssertFalse( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 10, + averageMs: 18, + maxMs: 35, + thresholdMs: 40, + nowUptime: 1000, + lastReportedUptime: nil + ) + ) + XCTAssertTrue( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 10, + averageMs: 18, + maxMs: 85, + thresholdMs: 40, + nowUptime: 1000, + lastReportedUptime: nil + ) + ) + } + + func testScrollLagCaptureRespectsCooldownWindow() { + XCTAssertFalse( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 12, + averageMs: 22, + maxMs: 90, + thresholdMs: 40, + nowUptime: 1200, + lastReportedUptime: 1005, + cooldown: 300 + ) + ) + XCTAssertTrue( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 12, + averageMs: 22, + maxMs: 90, + thresholdMs: 40, + nowUptime: 1406, + lastReportedUptime: 1005, + cooldown: 300 + ) + ) + } + func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { @@ -193,6 +466,37 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) } + func testTelemetryDefaultsToEnabledWhenUnset() { + let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated user defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.removeObject(forKey: TelemetrySettings.sendAnonymousTelemetryKey) + XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults)) + } + + func testTelemetryRespectsStoredPreference() { + let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated user defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set(true, forKey: TelemetrySettings.sendAnonymousTelemetryKey) + XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults)) + + defaults.set(false, forKey: TelemetrySettings.sendAnonymousTelemetryKey) + XCTAssertFalse(TelemetrySettings.isEnabled(defaults: defaults)) + } + private func rgb255(_ color: NSColor) -> RGB { let srgb = color.usingColorSpace(.sRGB)! var red: CGFloat = 0 @@ -208,6 +512,243 @@ 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") + } +} + +@MainActor +final class WorkspaceChromeColorTests: XCTestCase { + func testBonsplitChromeHexIncludesAlphaWhenTranslucent() { + let color = NSColor( + srgbRed: 17.0 / 255.0, + green: 34.0 / 255.0, + blue: 51.0 / 255.0, + alpha: 1.0 + ) + + let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 0.5) + XCTAssertEqual(hex, "#1122337F") + } + + func testBonsplitChromeHexOmitsAlphaWhenOpaque() { + let color = NSColor( + srgbRed: 17.0 / 255.0, + green: 34.0 / 255.0, + blue: 51.0 / 255.0, + alpha: 1.0 + ) + + let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 1.0) + XCTAssertEqual(hex, "#112233") + } +} + +final class WindowTransparencyDecisionTests: XCTestCase { + private let sidebarBlendModeKey = "sidebarBlendMode" + private let bgGlassEnabledKey = "bgGlassEnabled" + + func testTranslucentOpacityForcesClearWindowBackgroundOutsideSidebarBlendModePath() { + withTemporaryWindowBackgroundDefaults { + let defaults = UserDefaults.standard + defaults.set("withinWindow", forKey: sidebarBlendModeKey) + defaults.set(false, forKey: bgGlassEnabledKey) + + XCTAssertFalse(cmuxShouldUseTransparentBackgroundWindow()) + XCTAssertTrue(cmuxShouldUseClearWindowBackground(for: 0.80)) + XCTAssertFalse(cmuxShouldUseClearWindowBackground(for: 1.0)) + } + } + + func testBehindWindowGlassPathStillControlsTransparentWindowFallback() { + withTemporaryWindowBackgroundDefaults { + let defaults = UserDefaults.standard + defaults.set("behindWindow", forKey: sidebarBlendModeKey) + defaults.set(true, forKey: bgGlassEnabledKey) + + let expectedTransparentFallback = !WindowGlassEffect.isAvailable + XCTAssertEqual(cmuxShouldUseTransparentBackgroundWindow(), expectedTransparentFallback) + XCTAssertEqual( + cmuxShouldUseClearWindowBackground(for: 1.0), + expectedTransparentFallback + ) + } + } + + private func withTemporaryWindowBackgroundDefaults(_ body: () -> Void) { + let defaults = UserDefaults.standard + let originalBlendMode = defaults.object(forKey: sidebarBlendModeKey) + let originalGlassEnabled = defaults.object(forKey: bgGlassEnabledKey) + defer { + restoreDefaultsValue(originalBlendMode, key: sidebarBlendModeKey, defaults: defaults) + restoreDefaultsValue(originalGlassEnabled, key: bgGlassEnabledKey, defaults: defaults) + } + body() + } + + private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } +} + +final class WindowBackgroundSelectionGateTests: XCTestCase { + func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() { + let tabId = UUID() + let activeSelectedTabId = UUID() + + XCTAssertTrue( + GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: tabId, + owningManagerExists: true, + owningSelectedTabId: tabId, + activeSelectedTabId: activeSelectedTabId + ) + ) + } + + func testShouldApplyWindowBackgroundRejectsWhenOwningSelectionDiffers() { + let tabId = UUID() + + XCTAssertFalse( + GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: tabId, + owningManagerExists: true, + owningSelectedTabId: UUID(), + activeSelectedTabId: tabId + ) + ) + } + + func testShouldApplyWindowBackgroundAllowsWhenOwningManagerSelectionIsTemporarilyNil() { + let tabId = UUID() + + XCTAssertTrue( + GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: tabId, + owningManagerExists: true, + owningSelectedTabId: nil, + activeSelectedTabId: UUID() + ) + ) + } + + func testShouldApplyWindowBackgroundFallsBackToActiveSelection() { + let tabId = UUID() + + XCTAssertTrue( + GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: tabId, + owningManagerExists: false, + owningSelectedTabId: nil, + activeSelectedTabId: tabId + ) + ) + XCTAssertFalse( + GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: tabId, + owningManagerExists: false, + owningSelectedTabId: nil, + activeSelectedTabId: UUID() + ) + ) + } + + func testShouldApplyWindowBackgroundAllowsWhenNoSelectionContext() { + XCTAssertTrue( + GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: UUID(), + owningManagerExists: false, + owningSelectedTabId: nil, + activeSelectedTabId: nil + ) + ) + XCTAssertTrue( + GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: nil, + owningManagerExists: false, + owningSelectedTabId: nil, + activeSelectedTabId: nil + ) + ) + XCTAssertTrue( + GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: nil, + owningManagerExists: true, + owningSelectedTabId: UUID(), + activeSelectedTabId: UUID() + ) + ) + } +} + final class NotificationBurstCoalescerTests: XCTestCase { func testSignalsInSameBurstFlushOnce() { let coalescer = NotificationBurstCoalescer(delay: 0.01) @@ -271,6 +812,95 @@ 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) @@ -309,52 +939,6 @@ final class RecentlyClosedBrowserStackTests: XCTestCase { } } -final class TabManagerNotificationOrderingSourceTests: XCTestCase { - func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws { - let projectRoot = findProjectRoot() - let tabManagerURL = projectRoot.appendingPathComponent("Sources/TabManager.swift") - let source = try String(contentsOf: tabManagerURL, encoding: .utf8) - - guard let titleObserverStart = source.range(of: "forName: .ghosttyDidSetTitle"), - let focusObserverStart = source.range( - of: "forName: .ghosttyDidFocusSurface", - range: titleObserverStart.upperBound..<source.endIndex - ) else { - XCTFail("Failed to locate TabManager notification observer block in Sources/TabManager.swift") - return - } - - let block = String(source[titleObserverStart.lowerBound..<focusObserverStart.lowerBound]) - XCTAssertFalse( - block.contains("Task {"), - """ - The .ghosttyDidSetTitle observer must update model state in the notification callback. - Using Task can reorder updates and leave titlebar/toolbar one event behind. - """ - ) - XCTAssertTrue( - block.contains("MainActor.assumeIsolated"), - "Expected .ghosttyDidSetTitle observer to run synchronously on MainActor." - ) - XCTAssertTrue( - block.contains("enqueuePanelTitleUpdate"), - "Expected .ghosttyDidSetTitle observer to enqueue panel title updates." - ) - } - - private func findProjectRoot() -> URL { - var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() - for _ in 0..<10 { - let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return dir - } - dir = dir.deletingLastPathComponent() - } - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - } -} - final class SocketControlSettingsTests: XCTestCase { func testMigrateModeSupportsExpandedSocketModes() { XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off) @@ -470,6 +1054,88 @@ final class SocketControlSettingsTests: XCTestCase { "/tmp/cmux-staging.sock" ) } + + func testUntaggedDebugBundleBlockedWithoutLaunchTag() { + XCTAssertTrue( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testUntaggedDebugBundleAllowedWithLaunchTag() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["CMUX_TAG": "tests-v1"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testTaggedDebugBundleAllowedWithoutLaunchTag() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug.tests-v1", + isDebugBuild: true + ) + ) + } + + func testReleaseBuildIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: false + ) + ) + } + + func testXCTestLaunchIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testXCTestInjectBundleLaunchIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["XCInjectBundle": "/tmp/fake.xctest"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testXCTestDyldLaunchIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["DYLD_INSERT_LIBRARIES": "/usr/lib/libXCTestBundleInject.dylib"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() { + // XCUITest launches the app as a separate process without XCTest env vars. + // The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment. + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["CMUX_UI_TEST_MODE": "1"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } } final class PostHogAnalyticsPropertiesTests: XCTestCase { @@ -502,6 +1168,35 @@ final class PostHogAnalyticsPropertiesTests: XCTestCase { XCTAssertEqual(properties["app_build"] as? String, "230") } + func testHourlyActivePropertiesIncludeVersionAndBuild() { + let properties = PostHogAnalytics.hourlyActiveProperties( + hourUTC: "2026-02-21T14", + reason: "didBecomeActive", + infoDictionary: [ + "CFBundleShortVersionString": "0.31.0", + "CFBundleVersion": "230", + ] + ) + + XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14") + XCTAssertEqual(properties["reason"] as? String, "didBecomeActive") + XCTAssertEqual(properties["app_version"] as? String, "0.31.0") + XCTAssertEqual(properties["app_build"] as? String, "230") + } + + func testHourlyPropertiesOmitVersionFieldsWhenUnavailable() { + let properties = PostHogAnalytics.hourlyActiveProperties( + hourUTC: "2026-02-21T14", + reason: "activeTimer", + infoDictionary: [:] + ) + + XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14") + XCTAssertEqual(properties["reason"] as? String, "activeTimer") + XCTAssertNil(properties["app_version"]) + XCTAssertNil(properties["app_build"]) + } + func testPropertiesOmitVersionFieldsWhenUnavailable() { let superProperties = PostHogAnalytics.superProperties(infoDictionary: [:]) XCTAssertEqual(superProperties["platform"] as? String, "cmuxterm") @@ -518,6 +1213,331 @@ final class PostHogAnalyticsPropertiesTests: XCTestCase { XCTAssertNil(dailyProperties["app_version"]) XCTAssertNil(dailyProperties["app_build"]) } + + func testFlushPolicyIncludesDailyAndHourlyActiveEvents() { + XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_daily_active")) + XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_hourly_active")) + XCTAssertFalse(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_other_event")) + } +} + +final class GhosttyMouseFocusTests: XCTestCase { + func testShouldRequestFirstResponderForMouseFocusWhenEnabledAndWindowIsActive() { + XCTAssertTrue( + GhosttyNSView.shouldRequestFirstResponderForMouseFocus( + focusFollowsMouseEnabled: true, + pressedMouseButtons: 0, + appIsActive: true, + windowIsKey: true, + alreadyFirstResponder: false, + visibleInUI: true, + hasUsableGeometry: true, + hiddenInHierarchy: false + ) + ) + } + + func testShouldNotRequestFirstResponderWhenFocusFollowsMouseDisabled() { + XCTAssertFalse( + GhosttyNSView.shouldRequestFirstResponderForMouseFocus( + focusFollowsMouseEnabled: false, + pressedMouseButtons: 0, + appIsActive: true, + windowIsKey: true, + alreadyFirstResponder: false, + visibleInUI: true, + hasUsableGeometry: true, + hiddenInHierarchy: false + ) + ) + } + + func testShouldNotRequestFirstResponderDuringMouseDrag() { + XCTAssertFalse( + GhosttyNSView.shouldRequestFirstResponderForMouseFocus( + focusFollowsMouseEnabled: true, + pressedMouseButtons: 1, + appIsActive: true, + windowIsKey: true, + alreadyFirstResponder: false, + visibleInUI: true, + hasUsableGeometry: true, + hiddenInHierarchy: false + ) + ) + } + + func testShouldNotRequestFirstResponderWhenViewCannotSafelyReceiveFocus() { + XCTAssertFalse( + GhosttyNSView.shouldRequestFirstResponderForMouseFocus( + focusFollowsMouseEnabled: true, + pressedMouseButtons: 0, + appIsActive: true, + windowIsKey: true, + alreadyFirstResponder: false, + visibleInUI: true, + hasUsableGeometry: false, + hiddenInHierarchy: false + ) + ) + XCTAssertFalse( + GhosttyNSView.shouldRequestFirstResponderForMouseFocus( + focusFollowsMouseEnabled: true, + pressedMouseButtons: 0, + appIsActive: true, + windowIsKey: true, + alreadyFirstResponder: false, + visibleInUI: true, + hasUsableGeometry: true, + hiddenInHierarchy: true + ) + ) + } + + // MARK: - CJK Font Fallback + + private func withTempConfig( + _ contents: String, + body: (String) -> Void + ) throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let file = dir.appendingPathComponent("config") + try contents.write(to: file, atomically: true, encoding: .utf8) + body(file.path) + } + + // MARK: cjkFontMappings + + func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() { + let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])! + let fonts = Set(mappings.map(\.1)) + let ranges = mappings.map(\.0) + + XCTAssertTrue(fonts.contains("Hiragino Sans")) + XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana") + XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana") + XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs") + XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul") + } + + func testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean() { + let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"])! + let fonts = Set(mappings.map(\.1)) + let ranges = mappings.map(\.0) + + XCTAssertTrue(fonts.contains("Apple SD Gothic Neo")) + XCTAssertTrue(ranges.contains("U+AC00-U+D7AF"), "Should include Hangul Syllables") + XCTAssertTrue(ranges.contains("U+1100-U+11FF"), "Should include Hangul Jamo") + XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs") + XCTAssertFalse(ranges.contains("U+3040-U+309F"), "Should NOT include Hiragana") + } + + func testCJKFontMappingsReturnsPingFangForChinese() { + let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])! + XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" }) + + let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])! + XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" }) + + let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])! + XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" }) + } + + func testCJKFontMappingsReturnsNilForNonCJKLanguages() { + XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"])) + XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: [])) + } + + func testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges() { + let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])! + + let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0) + let sdGothicRanges = mappings.filter { $0.1 == "Apple SD Gothic Neo" }.map(\.0) + + XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino") + XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font") + XCTAssertTrue(sdGothicRanges.contains("U+AC00-U+D7AF"), "Hangul → Apple SD Gothic Neo") + XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino") + } + + // MARK: userConfigContainsCJKCodepointMap + + func testUserConfigContainsCJKCodepointMapDetectsPresence() throws { + try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) + } + } + + func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws { + try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in + XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) + } + } + + func testUserConfigContainsCJKCodepointMapIgnoresComments() throws { + try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in + XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) + } + } + + func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() { + let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config" + XCTAssertFalse( + GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]) + ) + } + + func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("fonts.conf") + try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try "font-family = Menlo\nconfig-file = \(included.path)\n" + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) + } + + func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("fonts.conf") + try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try "config-file = fonts.conf\n" + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) + } + + func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("fonts.conf") + try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try "config-file = \(included.path)?\n" + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) + } + + func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let fileA = dir.appendingPathComponent("a.conf") + let fileB = dir.appendingPathComponent("b.conf") + try "config-file = \(fileB.path)\n" + .write(to: fileA, atomically: true, encoding: .utf8) + try "config-file = \(fileA.path)\n" + .write(to: fileB, atomically: true, encoding: .utf8) + + // Should not hang; should return false since neither file has font-codepoint-map + XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path])) + } +} + +final class ZshShellIntegrationHandoffTests: XCTestCase { + func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws { + let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true) + + XCTAssertTrue(output.contains("PRECMD=1"), output) + XCTAssertTrue(output.contains("PREEXEC=1"), output) + XCTAssertTrue(output.contains("PRECMDS=_ghostty_precmd"), output) + } + + func testGhosttyPromptHooksDoNotLoadWithoutCmuxHandoffFlag() throws { + let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: false) + + XCTAssertTrue(output.contains("PRECMD=0"), output) + XCTAssertTrue(output.contains("PREEXEC=0"), output) + } + + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let userZdotdir = root.appendingPathComponent("zdotdir") + try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true) + try "\n".write(to: userZdotdir.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) + + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let cmuxZdotdir = repoRoot.appendingPathComponent("Resources/shell-integration") + let ghosttyResources = repoRoot.appendingPathComponent("ghostty/src") + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = [ + "-i", + "-c", + "(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " + + "print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " + + "PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\"" + ] + process.environment = [ + "HOME": root.path, + "TERM": "xterm-256color", + "SHELL": "/bin/zsh", + "USER": NSUserName(), + "ZDOTDIR": cmuxZdotdir.path, + "CMUX_ZSH_ZDOTDIR": userZdotdir.path, + "CMUX_SHELL_INTEGRATION": "0", + "GHOSTTY_RESOURCES_DIR": ghosttyResources.path, + ] + if cmuxLoadGhosttyIntegration { + process.environment?["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1" + } + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + let deadline = Date().addingTimeInterval(5) + while process.isRunning && Date() < deadline { + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + if process.isRunning { + process.terminate() + process.waitUntilExit() + XCTFail("Timed out waiting for zsh to exit") + } + + let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + XCTAssertEqual(process.terminationStatus, 0, error) + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } } final class BrowserInstallDetectorTests: XCTestCase { diff --git a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift new file mode 100644 index 00000000..e2718c9a --- /dev/null +++ b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift @@ -0,0 +1,62 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class GhosttyEnsureFocusWindowActivationTests: XCTestCase { + func testAllowsActivationForActiveManager() { + let activeManager = TabManager() + let otherManager = TabManager() + + XCTAssertTrue( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: activeManager, + targetTabManager: activeManager, + keyWindow: NSWindow(), + mainWindow: NSWindow() + ) + ) + XCTAssertFalse( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: activeManager, + targetTabManager: otherManager, + keyWindow: NSWindow(), + mainWindow: NSWindow() + ) + ) + } + + func testAllowsActivationWhenAppHasNoKeyAndNoMainWindow() { + let targetManager = TabManager() + + XCTAssertTrue( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: nil, + targetTabManager: targetManager, + keyWindow: nil, + mainWindow: nil + ) + ) + XCTAssertFalse( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: nil, + targetTabManager: targetManager, + keyWindow: NSWindow(), + mainWindow: nil + ) + ) + XCTAssertFalse( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: nil, + targetTabManager: targetManager, + keyWindow: nil, + mainWindow: NSWindow() + ) + ) + } +} diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift new file mode 100644 index 00000000..88d8f11c --- /dev/null +++ b/cmuxTests/SessionPersistenceTests.swift @@ -0,0 +1,873 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SessionPersistenceTests: XCTestCase { + func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + let snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) + + let loaded = SessionPersistenceStore.load(fileURL: snapshotURL) + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion) + XCTAssertEqual(loaded?.windows.count, 1) + XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs) + let frame = try XCTUnwrap(loaded?.windows.first?.frame) + XCTAssertEqual(frame.x, 10, accuracy: 0.001) + XCTAssertEqual(frame.y, 20, accuracy: 0.001) + XCTAssertEqual(frame.width, 900, accuracy: 0.001) + XCTAssertEqual(frame.height, 700, accuracy: 0.001) + XCTAssertEqual(loaded?.windows.first?.display?.displayID, 42) + let visibleFrame = try XCTUnwrap(loaded?.windows.first?.display?.visibleFrame) + XCTAssertEqual(visibleFrame.y, 25, accuracy: 0.001) + } + + func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + snapshot.windows[0].tabManager.workspaces[0].customColor = "#C0392B" + + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) + + let loaded = SessionPersistenceStore.load(fileURL: snapshotURL) + XCTAssertEqual( + loaded?.windows.first?.tabManager.workspaces.first?.customColor, + "#C0392B" + ) + } + + func testWorkspaceCustomColorDecodeSupportsMissingLegacyField() throws { + var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + snapshot.windows[0].tabManager.workspaces[0].customColor = nil + + let encoder = JSONEncoder() + let data = try encoder.encode(snapshot) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + XCTAssertFalse(json.contains("\"customColor\"")) + + let decoded = try JSONDecoder().decode(AppSessionSnapshot.self, from: data) + XCTAssertNil(decoded.windows.first?.tabManager.workspaces.first?.customColor) + } + + func testLoadRejectsSchemaVersionMismatch() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + XCTAssertTrue(SessionPersistenceStore.save(makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1), fileURL: snapshotURL)) + + XCTAssertNil(SessionPersistenceStore.load(fileURL: snapshotURL)) + } + + func testDefaultSnapshotPathSanitizesBundleIdentifier() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let path = SessionPersistenceStore.defaultSnapshotFileURL( + bundleIdentifier: "com.example/unsafe id", + appSupportDirectory: tempDir + ) + + XCTAssertNotNil(path) + XCTAssertTrue(path?.path.contains("com.example_unsafe_id") == true) + } + + func testRestorePolicySkipsWhenLaunchHasExplicitArguments() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "--window", "window:1"], + environment: [:] + ) + + XCTAssertFalse(shouldRestore) + } + + func testRestorePolicyAllowsFinderStyleLaunchArgumentsOnly() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "-psn_0_12345"], + environment: [:] + ) + + XCTAssertTrue(shouldRestore) + } + + func testRestorePolicySkipsWhenRunningUnderXCTest() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux"], + environment: ["XCTestConfigurationFilePath": "/tmp/xctest.xctestconfiguration"] + ) + + XCTAssertFalse(shouldRestore) + } + + func testSidebarWidthSanitizationClampsToPolicyRange() { + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(-20), + SessionPersistencePolicy.minimumSidebarWidth, + accuracy: 0.001 + ) + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(10_000), + SessionPersistencePolicy.maximumSidebarWidth, + accuracy: 0.001 + ) + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(nil), + SessionPersistencePolicy.defaultSidebarWidth, + accuracy: 0.001 + ) + } + + func testSessionRectSnapshotEncodesXYWidthHeightKeys() throws { + let snapshot = SessionRectSnapshot(x: 101.25, y: 202.5, width: 903.75, height: 704.5) + let data = try JSONEncoder().encode(snapshot) + let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Double]) + + XCTAssertEqual(Set(object.keys), Set(["x", "y", "width", "height"])) + XCTAssertEqual(try XCTUnwrap(object["x"]), 101.25, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(object["y"]), 202.5, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(object["width"]), 903.75, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(object["height"]), 704.5, accuracy: 0.001) + } + + func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws { + let source = SessionBrowserPanelSnapshot( + urlString: "https://example.com/current", + shouldRenderWebView: true, + pageZoom: 1.2, + developerToolsVisible: true, + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ] + ) + + let data = try JSONEncoder().encode(source) + let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data) + XCTAssertEqual(decoded.urlString, source.urlString) + XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings) + XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings) + } + + func testSessionBrowserPanelSnapshotHistoryDecodesWhenKeysAreMissing() throws { + let json = """ + { + "urlString": "https://example.com/current", + "shouldRenderWebView": true, + "pageZoom": 1.0, + "developerToolsVisible": false + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json) + XCTAssertEqual(decoded.urlString, "https://example.com/current") + XCTAssertNil(decoded.backHistoryURLStrings) + XCTAssertNil(decoded.forwardHistoryURLStrings) + } + + func testScrollbackReplayEnvironmentWritesReplayFile() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: "line one\nline two\n", + tempDirectory: tempDir + ) + + let path = environment[SessionScrollbackReplayStore.environmentKey] + XCTAssertNotNil(path) + XCTAssertTrue(path?.hasPrefix(tempDir.path) == true) + + guard let path else { return } + let contents = try? String(contentsOfFile: path, encoding: .utf8) + XCTAssertEqual(contents, "line one\nline two\n") + } + + func testScrollbackReplayEnvironmentSkipsWhitespaceOnlyContent() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: " \n\t ", + tempDirectory: tempDir + ) + + XCTAssertTrue(environment.isEmpty) + } + + func testScrollbackReplayEnvironmentPreservesANSIColorSequences() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let red = "\u{001B}[31m" + let reset = "\u{001B}[0m" + let source = "\(red)RED\(reset)\n" + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: source, + tempDirectory: tempDir + ) + + guard let path = environment[SessionScrollbackReplayStore.environmentKey] else { + XCTFail("Expected replay file path") + return + } + + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { + XCTFail("Expected replay file contents") + return + } + + XCTAssertTrue(contents.contains("\(red)RED\(reset)")) + XCTAssertTrue(contents.hasPrefix(reset)) + XCTAssertTrue(contents.hasSuffix(reset)) + } + + func testTruncatedScrollbackAvoidsLeadingPartialANSICSISequence() { + let maxChars = SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + let source = "\u{001B}[31m" + + String(repeating: "X", count: maxChars - 7) + + "\u{001B}[0m" + + guard let truncated = SessionPersistencePolicy.truncatedScrollback(source) else { + XCTFail("Expected truncated scrollback") + return + } + + XCTAssertFalse(truncated.hasPrefix("31m")) + XCTAssertFalse(truncated.hasPrefix("[31m")) + XCTAssertFalse(truncated.hasPrefix("m")) + } + + func testNormalizedExportedScreenPathAcceptsAbsoluteAndFileURL() { + XCTAssertEqual( + TerminalController.normalizedExportedScreenPath("/tmp/cmux-screen.txt"), + "/tmp/cmux-screen.txt" + ) + XCTAssertEqual( + TerminalController.normalizedExportedScreenPath(" file:///tmp/cmux-screen.txt "), + "/tmp/cmux-screen.txt" + ) + } + + func testNormalizedExportedScreenPathRejectsRelativeAndWhitespace() { + XCTAssertNil(TerminalController.normalizedExportedScreenPath("relative/path.txt")) + XCTAssertNil(TerminalController.normalizedExportedScreenPath(" ")) + XCTAssertNil(TerminalController.normalizedExportedScreenPath(nil)) + } + + func testShouldRemoveExportedScreenDirectoryOnlyWithinTemporaryRoot() { + let tempRoot = URL(fileURLWithPath: "/tmp") + .appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true) + let tempFile = tempRoot + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("screen.txt", isDirectory: false) + let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt") + + XCTAssertTrue( + TerminalController.shouldRemoveExportedScreenDirectory( + fileURL: tempFile, + temporaryDirectory: tempRoot + ) + ) + XCTAssertFalse( + TerminalController.shouldRemoveExportedScreenDirectory( + fileURL: outsideFile, + temporaryDirectory: tempRoot + ) + ) + } + + func testShouldRemoveExportedScreenFileOnlyWithinTemporaryRoot() { + let tempRoot = URL(fileURLWithPath: "/tmp") + .appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true) + let tempFile = tempRoot + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("screen.txt", isDirectory: false) + let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt") + + XCTAssertTrue( + TerminalController.shouldRemoveExportedScreenFile( + fileURL: tempFile, + temporaryDirectory: tempRoot + ) + ) + XCTAssertFalse( + TerminalController.shouldRemoveExportedScreenFile( + fileURL: outsideFile, + temporaryDirectory: tempRoot + ) + ) + } + + func testWindowUnregisterSnapshotPersistencePolicy() { + XCTAssertTrue( + AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: false) + ) + XCTAssertFalse( + AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true) + ) + XCTAssertTrue( + AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: false) + ) + XCTAssertFalse( + AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: true) + ) + } + + func testShouldSkipSessionSaveDuringStartupRestorePolicy() { + XCTAssertTrue( + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: true, + includeScrollback: false + ) + ) + XCTAssertFalse( + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: true, + includeScrollback: true + ) + ) + XCTAssertFalse( + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: false, + includeScrollback: false + ) + ) + } + + func testSessionAutosaveTickPolicySkipsWhenTerminating() { + XCTAssertTrue( + AppDelegate.shouldRunSessionAutosaveTick(isTerminatingApp: false) + ) + XCTAssertFalse( + AppDelegate.shouldRunSessionAutosaveTick(isTerminatingApp: true) + ) + } + + func testSessionSnapshotSynchronousWritePolicy() { + XCTAssertFalse( + AppDelegate.shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: false, + includeScrollback: false + ) + ) + XCTAssertFalse( + AppDelegate.shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: false, + includeScrollback: true + ) + ) + XCTAssertFalse( + AppDelegate.shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: true, + includeScrollback: false + ) + ) + XCTAssertTrue( + AppDelegate.shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: true, + includeScrollback: true + ) + ) + } + + func testUnchangedAutosaveFingerprintSkipsWithinStalenessWindow() { + let now = Date() + XCTAssertTrue( + AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: false, + includeScrollback: false, + previousFingerprint: 1234, + currentFingerprint: 1234, + lastPersistedAt: now.addingTimeInterval(-5), + now: now, + maximumAutosaveSkippableInterval: 60 + ) + ) + } + + func testUnchangedAutosaveFingerprintDoesNotSkipAfterStalenessWindow() { + let now = Date() + XCTAssertFalse( + AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: false, + includeScrollback: false, + previousFingerprint: 1234, + currentFingerprint: 1234, + lastPersistedAt: now.addingTimeInterval(-120), + now: now, + maximumAutosaveSkippableInterval: 60 + ) + ) + } + + func testUnchangedAutosaveFingerprintNeverSkipsTerminatingOrScrollbackWrites() { + let now = Date() + XCTAssertFalse( + AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: true, + includeScrollback: false, + previousFingerprint: 1234, + currentFingerprint: 1234, + lastPersistedAt: now.addingTimeInterval(-1), + now: now + ) + ) + XCTAssertFalse( + AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: false, + includeScrollback: true, + previousFingerprint: 1234, + currentFingerprint: 1234, + lastPersistedAt: now.addingTimeInterval(-1), + now: now + ) + ) + } + + func testResolvedWindowFramePrefersSavedDisplayIdentity() { + let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800), + visibleFrame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800) + ) + + // Display 1 and 2 swapped horizontal positions between snapshot and restore. + let display1 = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800) + ) + let display2 = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [display1, display2], + fallbackDisplay: display1 + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(display2.visibleFrame.intersects(restored)) + XCTAssertFalse(display1.visibleFrame.intersects(restored)) + XCTAssertEqual(restored.width, 600, accuracy: 0.001) + XCTAssertEqual(restored.height, 400, accuracy: 0.001) + XCTAssertEqual(restored.minX, 200, accuracy: 0.001) + XCTAssertEqual(restored.minY, 100, accuracy: 0.001) + } + + func testResolvedWindowFrameKeepsIntersectingFrameWithoutDisplayMetadata() { + let savedFrame = SessionRectSnapshot(x: 120, y: 80, width: 500, height: 350) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: nil, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 120, accuracy: 0.001) + XCTAssertEqual(restored.minY, 80, accuracy: 0.001) + XCTAssertEqual(restored.width, 500, accuracy: 0.001) + XCTAssertEqual(restored.height, 350, accuracy: 0.001) + } + + func testResolvedStartupPrimaryWindowFrameFallsBackToPersistedGeometryWhenPrimaryMissing() { + let fallbackFrame = SessionRectSnapshot(x: 180, y: 140, width: 900, height: 640) + let fallbackDisplay = SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000) + ) + + let restored = AppDelegate.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackFrame, + fallbackDisplaySnapshot: fallbackDisplay, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 180, accuracy: 0.001) + XCTAssertEqual(restored.minY, 140, accuracy: 0.001) + XCTAssertEqual(restored.width, 900, accuracy: 0.001) + XCTAssertEqual(restored.height, 640, accuracy: 0.001) + } + + func testResolvedStartupPrimaryWindowFramePrefersPrimarySnapshotOverFallback() { + let primarySnapshot = SessionWindowSnapshot( + frame: SessionRectSnapshot(x: 220, y: 160, width: 980, height: 700), + display: SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ), + tabManager: SessionTabManagerSnapshot(selectedWorkspaceIndex: nil, workspaces: []), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 220) + ) + let fallbackFrame = SessionRectSnapshot(x: 40, y: 30, width: 700, height: 500) + let fallbackDisplay = SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000) + ) + + let restored = AppDelegate.resolvedStartupPrimaryWindowFrame( + primarySnapshot: primarySnapshot, + fallbackFrame: fallbackFrame, + fallbackDisplaySnapshot: fallbackDisplay, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 220, accuracy: 0.001) + XCTAssertEqual(restored.minY, 160, accuracy: 0.001) + XCTAssertEqual(restored.width, 980, accuracy: 0.001) + XCTAssertEqual(restored.height, 700, accuracy: 0.001) + } + + func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() { + let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: nil, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(display.visibleFrame.contains(restored)) + XCTAssertEqual(restored.minX, 50, accuracy: 0.001) + XCTAssertEqual(restored.minY, 50, accuracy: 0.001) + XCTAssertEqual(restored.width, 900, accuracy: 0.001) + XCTAssertEqual(restored.height, 700, accuracy: 0.001) + } + + func testResolvedWindowFramePreservesExactGeometryWhenDisplayIsUnchanged() { + let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410) + ) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 2_560, height: 1_440), + visibleFrame: CGRect(x: 0, y: 0, width: 2_560, height: 1_410) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 1_303, accuracy: 0.001) + XCTAssertEqual(restored.minY, -90, accuracy: 0.001) + XCTAssertEqual(restored.width, 1_280, accuracy: 0.001) + XCTAssertEqual(restored.height, 1_410, accuracy: 0.001) + } + + func testResolvedWindowFrameClampsWhenDisplayGeometryChangesEvenWithSameDisplayID() { + let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410) + ) + let resizedDisplay = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080), + visibleFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_050) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [resizedDisplay], + fallbackDisplay: resizedDisplay + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(resizedDisplay.visibleFrame.contains(restored)) + XCTAssertNotEqual(restored.minX, 1_303, "Changed display geometry should clamp/remap frame") + XCTAssertNotEqual(restored.minY, -90, "Changed display geometry should clamp/remap frame") + } + + func testResolvedSnapshotTerminalScrollbackPrefersCaptured() { + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: "captured-value", + fallbackScrollback: "fallback-value" + ) + + XCTAssertEqual(resolved, "captured-value") + } + + func testResolvedSnapshotTerminalScrollbackFallsBackWhenCaptureMissing() { + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: nil, + fallbackScrollback: "fallback-value" + ) + + XCTAssertEqual(resolved, "fallback-value") + } + + func testResolvedSnapshotTerminalScrollbackTruncatesFallback() { + let oversizedFallback = String( + repeating: "x", + count: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + 37 + ) + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: nil, + fallbackScrollback: oversizedFallback + ) + + XCTAssertEqual( + resolved?.count, + SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + ) + } + + private func makeSnapshot(version: Int) -> AppSessionSnapshot { + let workspace = SessionWorkspaceSnapshot( + processTitle: "Terminal", + customTitle: "Restored", + customColor: nil, + isPinned: true, + currentDirectory: "/tmp", + focusedPanelId: nil, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil + ) + + let tabManager = SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [workspace] + ) + + let window = SessionWindowSnapshot( + frame: SessionRectSnapshot(x: 10, y: 20, width: 900, height: 700), + display: SessionDisplaySnapshot( + displayID: 42, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1920, height: 1200), + visibleFrame: SessionRectSnapshot(x: 0, y: 25, width: 1920, height: 1175) + ), + tabManager: tabManager, + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 240) + ) + + return AppSessionSnapshot( + version: version, + createdAt: Date().timeIntervalSince1970, + windows: [window] + ) + } +} + +final class SocketListenerAcceptPolicyTests: XCTestCase { + func testAcceptErrorClassificationBucketsExpectedErrnos() { + XCTAssertEqual( + TerminalController.acceptErrorClassification(errnoCode: EINTR), + "immediate_retry" + ) + XCTAssertEqual( + TerminalController.acceptErrorClassification(errnoCode: ECONNABORTED), + "immediate_retry" + ) + XCTAssertEqual( + TerminalController.acceptErrorClassification(errnoCode: EMFILE), + "resource_pressure" + ) + XCTAssertEqual( + TerminalController.acceptErrorClassification(errnoCode: ENOMEM), + "resource_pressure" + ) + XCTAssertEqual( + TerminalController.acceptErrorClassification(errnoCode: EBADF), + "fatal" + ) + XCTAssertEqual( + TerminalController.acceptErrorClassification(errnoCode: EINVAL), + "fatal" + ) + } + + func testAcceptErrorPolicySignalsRearmOnlyForFatalErrors() { + XCTAssertTrue(TerminalController.shouldRearmListenerForAcceptError(errnoCode: EBADF)) + XCTAssertTrue(TerminalController.shouldRearmListenerForAcceptError(errnoCode: ENOTSOCK)) + XCTAssertFalse(TerminalController.shouldRearmListenerForAcceptError(errnoCode: EMFILE)) + XCTAssertFalse(TerminalController.shouldRearmListenerForAcceptError(errnoCode: EINTR)) + } + + func testAcceptErrorPolicyRearmsAfterPersistentFailures() { + XCTAssertFalse(TerminalController.shouldRearmForConsecutiveAcceptFailures(consecutiveFailures: 0)) + XCTAssertFalse(TerminalController.shouldRearmForConsecutiveAcceptFailures(consecutiveFailures: 49)) + XCTAssertTrue(TerminalController.shouldRearmForConsecutiveAcceptFailures(consecutiveFailures: 50)) + XCTAssertTrue(TerminalController.shouldRearmForConsecutiveAcceptFailures(consecutiveFailures: 120)) + } + + func testAcceptFailureBackoffIsExponentialAndCapped() { + XCTAssertEqual( + TerminalController.acceptFailureBackoffMilliseconds(consecutiveFailures: 0), + 0 + ) + XCTAssertEqual( + TerminalController.acceptFailureBackoffMilliseconds(consecutiveFailures: 1), + 10 + ) + XCTAssertEqual( + TerminalController.acceptFailureBackoffMilliseconds(consecutiveFailures: 2), + 20 + ) + XCTAssertEqual( + TerminalController.acceptFailureBackoffMilliseconds(consecutiveFailures: 6), + 320 + ) + XCTAssertEqual( + TerminalController.acceptFailureBackoffMilliseconds(consecutiveFailures: 12), + 5_000 + ) + XCTAssertEqual( + TerminalController.acceptFailureBackoffMilliseconds(consecutiveFailures: 50), + 5_000 + ) + } + + func testAcceptFailureRearmDelayAppliesMinimumThrottle() { + XCTAssertEqual( + TerminalController.acceptFailureRearmDelayMilliseconds(consecutiveFailures: 0), + 100 + ) + XCTAssertEqual( + TerminalController.acceptFailureRearmDelayMilliseconds(consecutiveFailures: 1), + 100 + ) + XCTAssertEqual( + TerminalController.acceptFailureRearmDelayMilliseconds(consecutiveFailures: 2), + 100 + ) + XCTAssertEqual( + TerminalController.acceptFailureRearmDelayMilliseconds(consecutiveFailures: 6), + 320 + ) + XCTAssertEqual( + TerminalController.acceptFailureRearmDelayMilliseconds(consecutiveFailures: 12), + 5_000 + ) + } + + func testAcceptFailureBreadcrumbSamplingPrefersEarlyAndPowerOfTwoMilestones() { + XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 1)) + XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 2)) + XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 3)) + XCTAssertFalse(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 5)) + XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 8)) + XCTAssertFalse(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 9)) + XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 16)) + } + + func testAcceptLoopCleanupUnlinkPolicySkipsDuringListenerStartup() { + XCTAssertFalse( + TerminalController.shouldUnlinkSocketPathAfterAcceptLoopCleanup( + pathMatches: true, + isRunning: false, + activeGeneration: 0, + listenerStartInProgress: true + ) + ) + XCTAssertFalse( + TerminalController.shouldUnlinkSocketPathAfterAcceptLoopCleanup( + pathMatches: false, + isRunning: false, + activeGeneration: 0, + listenerStartInProgress: false + ) + ) + XCTAssertFalse( + TerminalController.shouldUnlinkSocketPathAfterAcceptLoopCleanup( + pathMatches: true, + isRunning: true, + activeGeneration: 7, + listenerStartInProgress: false + ) + ) + XCTAssertTrue( + TerminalController.shouldUnlinkSocketPathAfterAcceptLoopCleanup( + pathMatches: true, + isRunning: false, + activeGeneration: 0, + listenerStartInProgress: false + ) + ) + } +} diff --git a/cmuxTests/SocketControlPasswordStoreTests.swift b/cmuxTests/SocketControlPasswordStoreTests.swift new file mode 100644 index 00000000..ea45e661 --- /dev/null +++ b/cmuxTests/SocketControlPasswordStoreTests.swift @@ -0,0 +1,333 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SocketControlPasswordStoreTests: XCTestCase { + override func setUp() { + super.setUp() + SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests() + } + + override func tearDown() { + SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests() + super.tearDown() + } + + func testSaveLoadAndClearRoundTripUsesFileStorage() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false) + + XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL)) + + try SocketControlPasswordStore.savePassword("hunter2", fileURL: fileURL) + XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "hunter2") + XCTAssertTrue(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL)) + + try SocketControlPasswordStore.clearPassword(fileURL: fileURL) + XCTAssertNil(try SocketControlPasswordStore.loadPassword(fileURL: fileURL)) + XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL)) + } + + func testConfiguredPasswordPrefersEnvironmentOverStoredFile() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false) + try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL) + + let environment = [SocketControlSettings.socketPasswordEnvKey: "env-secret"] + let configured = SocketControlPasswordStore.configuredPassword( + environment: environment, + fileURL: fileURL + ) + XCTAssertEqual(configured, "env-secret") + } + + func testConfiguredPasswordLazyKeychainFallbackReadsOnlyOnceAndCaches() { + var readCount = 0 + + let withoutFallback = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: false, + loadKeychainPassword: { + readCount += 1 + return "legacy-secret" + } + ) + XCTAssertNil(withoutFallback) + XCTAssertEqual(readCount, 0) + + let firstWithFallback = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return "legacy-secret" + } + ) + XCTAssertEqual(firstWithFallback, "legacy-secret") + XCTAssertEqual(readCount, 1) + + let secondWithFallback = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return "new-secret" + } + ) + XCTAssertEqual(secondWithFallback, "legacy-secret") + XCTAssertEqual(readCount, 1) + } + + func testConfiguredPasswordLazyKeychainFallbackCachesMissingValue() { + var readCount = 0 + + let first = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return nil + } + ) + XCTAssertNil(first) + XCTAssertEqual(readCount, 1) + + let second = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return "should-not-be-read" + } + ) + XCTAssertNil(second) + XCTAssertEqual(readCount, 1) + } + + func testConfiguredPasswordPrefersStoredFileOverLazyKeychainFallback() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false) + try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL) + + var readCount = 0 + let configured = SocketControlPasswordStore.configuredPassword( + environment: [:], + fileURL: fileURL, + allowLazyKeychainFallback: true, + loadKeychainPassword: { + readCount += 1 + return "legacy-secret" + } + ) + + XCTAssertEqual(configured, "stored-secret") + XCTAssertEqual(readCount, 0) + } + + func testHasConfiguredAndVerifyReuseSingleLazyKeychainRead() { + var readCount = 0 + let loader = { + readCount += 1 + return "legacy-secret" + } + + XCTAssertTrue( + SocketControlPasswordStore.hasConfiguredPassword( + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: loader + ) + ) + XCTAssertEqual(readCount, 1) + + XCTAssertTrue( + SocketControlPasswordStore.verify( + password: "legacy-secret", + environment: [:], + fileURL: nil, + allowLazyKeychainFallback: true, + loadKeychainPassword: loader + ) + ) + XCTAssertEqual(readCount, 1) + } + + func testDefaultPasswordFileURLUsesCmuxAppSupportPath() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let resolved = SocketControlPasswordStore.defaultPasswordFileURL(appSupportDirectory: tempDir) + XCTAssertEqual( + resolved?.path, + tempDir.appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("socket-control-password", isDirectory: false).path + ) + } + + func testLegacyKeychainMigrationCopiesPasswordDeletesLegacyAndRunsOnlyOnce() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false) + let defaultsSuiteName = "cmux-socket-password-migration-tests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: defaultsSuiteName) else { + XCTFail("Expected isolated UserDefaults suite for migration test") + return + } + defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } + + var lookupCount = 0 + var deleteCount = 0 + + SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded( + defaults: defaults, + fileURL: fileURL, + loadLegacyPassword: { + lookupCount += 1 + return "legacy-secret" + }, + deleteLegacyPassword: { + deleteCount += 1 + return true + } + ) + + XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret") + XCTAssertEqual(lookupCount, 1) + XCTAssertEqual(deleteCount, 1) + + SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded( + defaults: defaults, + fileURL: fileURL, + loadLegacyPassword: { + lookupCount += 1 + return "new-value" + }, + deleteLegacyPassword: { + deleteCount += 1 + return true + } + ) + + XCTAssertEqual(lookupCount, 1) + XCTAssertEqual(deleteCount, 1) + XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret") + } +} + +final class CmuxCLIPathInstallerTests: XCTestCase { + func testInstallAndUninstallRoundTripWithoutAdministratorPrivileges() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let bundledCLIURL = root + .appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false) + try fileManager.createDirectory( + at: bundledCLIURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8) + + let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false) + + var privilegedInstallCallCount = 0 + var privilegedUninstallCallCount = 0 + let installer = CmuxCLIPathInstaller( + fileManager: fileManager, + destinationURL: destinationURL, + bundledCLIURLProvider: { bundledCLIURL }, + expectedBundledCLIPath: bundledCLIURL.path, + privilegedInstaller: { _, _ in privilegedInstallCallCount += 1 }, + privilegedUninstaller: { _ in privilegedUninstallCallCount += 1 } + ) + + let installOutcome = try installer.install() + XCTAssertFalse(installOutcome.usedAdministratorPrivileges) + XCTAssertEqual(privilegedInstallCallCount, 0) + XCTAssertTrue(installer.isInstalled()) + XCTAssertEqual( + try fileManager.destinationOfSymbolicLink(atPath: destinationURL.path), + bundledCLIURL.path + ) + + let uninstallOutcome = try installer.uninstall() + XCTAssertFalse(uninstallOutcome.usedAdministratorPrivileges) + XCTAssertTrue(uninstallOutcome.removedExistingEntry) + XCTAssertEqual(privilegedUninstallCallCount, 0) + XCTAssertFalse(fileManager.fileExists(atPath: destinationURL.path)) + XCTAssertFalse(installer.isInstalled()) + } + + func testInstallFallsBackToAdministratorFlowWhenDestinationIsNotWritable() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let bundledCLIURL = root + .appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false) + try fileManager.createDirectory( + at: bundledCLIURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8) + + let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false) + let destinationDir = destinationURL.deletingLastPathComponent() + try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true) + try fileManager.setAttributes([.posixPermissions: 0o555], ofItemAtPath: destinationDir.path) + defer { + try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path) + } + + var privilegedInstallCallCount = 0 + let installer = CmuxCLIPathInstaller( + fileManager: fileManager, + destinationURL: destinationURL, + bundledCLIURLProvider: { bundledCLIURL }, + expectedBundledCLIPath: bundledCLIURL.path, + privilegedInstaller: { sourceURL, privilegedDestinationURL in + privilegedInstallCallCount += 1 + XCTAssertEqual(sourceURL.standardizedFileURL, bundledCLIURL.standardizedFileURL) + XCTAssertEqual(privilegedDestinationURL.standardizedFileURL, destinationURL.standardizedFileURL) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path) + try fileManager.createSymbolicLink(at: privilegedDestinationURL, withDestinationURL: sourceURL) + } + ) + + let installOutcome = try installer.install() + XCTAssertTrue(installOutcome.usedAdministratorPrivileges) + XCTAssertEqual(privilegedInstallCallCount, 1) + XCTAssertTrue(installer.isInstalled()) + } +} diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 63348c49..1225c111 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -8,101 +8,6 @@ import AppKit @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. -final class UpdatePillReleaseVisibilityTests: XCTestCase { - - /// Source files that must show UpdatePill without #if DEBUG guards. - private let filesToCheck = [ - "Sources/Update/UpdateTitlebarAccessory.swift", - "Sources/ContentView.swift", - ] - - func testUpdatePillNotGatedBehindDebug() throws { - let projectRoot = findProjectRoot() - - for relativePath in filesToCheck { - let url = projectRoot.appendingPathComponent(relativePath) - let source = try String(contentsOf: url, encoding: .utf8) - let lines = source.components(separatedBy: .newlines) - - // Track #if DEBUG nesting depth. - var debugDepth = 0 - - for (index, line) in lines.enumerated() { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if trimmed == "#if DEBUG" || trimmed.hasPrefix("#if DEBUG ") { - debugDepth += 1 - } else if trimmed == "#endif" && debugDepth > 0 { - debugDepth -= 1 - } else if trimmed == "#else" && debugDepth > 0 { - // #else inside #if DEBUG means we're in the non-debug branch — that's fine. - // But UpdatePill in the #if DEBUG branch (before #else) is the problem. - // We handle this by only flagging UpdatePill when debugDepth > 0 and we haven't - // hit #else yet. For simplicity, treat #else as flipping out of the guarded section. - debugDepth -= 1 - } - - if debugDepth > 0 && trimmed.contains("UpdatePill") { - XCTFail( - """ - \(relativePath):\(index + 1) — UpdatePill is inside #if DEBUG. \ - This hides the update UI in Release builds. Remove the #if DEBUG guard \ - or move UpdatePill to the #else branch. - """ - ) - } - } - } - } - - private func findProjectRoot() -> URL { - // Walk up from the test bundle to find the project root (contains GhosttyTabs.xcodeproj). - var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() - for _ in 0..<10 { - let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return dir - } - dir = dir.deletingLastPathComponent() - } - // Fallback: assume CWD is project root. - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - } -} - -/// Regression test: ensure WKWebView can load HTTP development URLs (e.g. *.localtest.me). -final class AppTransportSecurityTests: XCTestCase { - func testInfoPlistAllowsArbitraryLoadsInWebContent() throws { - let projectRoot = findProjectRoot() - let infoPlistURL = projectRoot.appendingPathComponent("Resources/Info.plist") - let data = try Data(contentsOf: infoPlistURL) - var format = PropertyListSerialization.PropertyListFormat.xml - let plist = try XCTUnwrap( - PropertyListSerialization.propertyList(from: data, options: [], format: &format) as? [String: Any] - ) - let ats = try XCTUnwrap(plist["NSAppTransportSecurity"] as? [String: Any]) - XCTAssertEqual( - ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool, - true, - "Resources/Info.plist must allow HTTP loads in WKWebView for local dev hostnames." - ) - } - - private func findProjectRoot() -> URL { - var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() - for _ in 0..<10 { - let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return dir - } - dir = dir.deletingLastPathComponent() - } - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - } -} - final class BrowserInsecureHTTPSettingsTests: XCTestCase { func testDefaultAllowlistPatternsArePresent() { XCTAssertEqual( @@ -222,44 +127,68 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase { } } -/// Regression test: ensure new terminal windows are born in full-size content mode so -/// titlebar/content offsets are correct before the first resize. -final class MainWindowLayoutStyleTests: XCTestCase { - func testCreateMainWindowUsesFullSizeContentViewStyleMask() throws { - let projectRoot = findProjectRoot() - let appDelegateURL = projectRoot.appendingPathComponent("Sources/AppDelegate.swift") - let source = try String(contentsOf: appDelegateURL, encoding: .utf8) - - guard let start = source.range(of: "func createMainWindow("), - let end = source.range(of: "@objc func checkForUpdates", range: start.upperBound..<source.endIndex) else { - XCTFail("Could not locate createMainWindow block in Sources/AppDelegate.swift") - return - } - - let block = String(source[start.lowerBound..<end.lowerBound]) - let regex = try NSRegularExpression( - pattern: #"styleMask:\s*\[[^\]]*\.fullSizeContentView"#, - options: [.dotMatchesLineSeparators] +final class TitlebarControlsSizingPolicyTests: XCTestCase { + func testSchedulePolicyRequiresMeaningfulViewSizeChange() { + XCTAssertFalse(titlebarControlsShouldScheduleForViewSizeChange(previous: .zero, current: .zero)) + XCTAssertTrue( + titlebarControlsShouldScheduleForViewSizeChange( + previous: .zero, + current: NSSize(width: 240, height: 38) + ) ) - let range = NSRange(block.startIndex..<block.endIndex, in: block) - XCTAssertNotNil( - regex.firstMatch(in: block, options: [], range: range), - """ - createMainWindow must include `.fullSizeContentView` in the NSWindow style mask. - Without it, initial titlebar/content offsets can be wrong until a manual resize. - """ + XCTAssertFalse( + titlebarControlsShouldScheduleForViewSizeChange( + previous: NSSize(width: 240, height: 38), + current: NSSize(width: 240.2, height: 38.1) + ) + ) + XCTAssertTrue( + titlebarControlsShouldScheduleForViewSizeChange( + previous: NSSize(width: 240, height: 38), + current: NSSize(width: 247, height: 38) + ) ) } - private func findProjectRoot() -> URL { - var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() - for _ in 0..<10 { - let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return dir - } - dir = dir.deletingLastPathComponent() + func testLayoutApplyPolicySkipsEquivalentSnapshots() { + let baseline = TitlebarControlsLayoutSnapshot( + contentSize: NSSize(width: 128, height: 22), + containerHeight: 28, + yOffset: 3 + ) + XCTAssertTrue(titlebarControlsShouldApplyLayout(previous: nil, next: baseline)) + XCTAssertFalse(titlebarControlsShouldApplyLayout(previous: baseline, next: baseline)) + + let changed = TitlebarControlsLayoutSnapshot( + contentSize: NSSize(width: 132, height: 22), + containerHeight: 28, + yOffset: 3 + ) + XCTAssertTrue(titlebarControlsShouldApplyLayout(previous: baseline, next: changed)) + } + + func testShortcutHintVerticalOffsetKeepsPillInsideButtonLane() { + for style in TitlebarControlsStyle.allCases { + let config = style.config + let hintHeight = titlebarShortcutHintHeight(for: config) + let verticalOffset = titlebarShortcutHintVerticalOffset(for: config) + + XCTAssertGreaterThanOrEqual(verticalOffset, 0, "Expected non-negative hint offset for style \(style)") + XCTAssertLessThanOrEqual( + verticalOffset + hintHeight, + config.buttonSize, + "Expected hint pill to fit within the titlebar button lane for style \(style)" + ) } - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } +} + +final class TitlebarControlsHoverPolicyTests: XCTestCase { + func testHoverTrackingOnlyEnabledForHoverBackgroundStyles() { + XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.classic.config)) + XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.compact.config)) + XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.roomy.config)) + XCTAssertTrue(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.pillGroup.config)) + XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.softButtons.config)) } } diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift new file mode 100644 index 00000000..6e8d62e3 --- /dev/null +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -0,0 +1,49 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class WorkspaceContentViewVisibilityTests: XCTestCase { + func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() { + XCTAssertFalse( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: false, + isSelectedInPane: true, + isFocused: true + ) + ) + } + + func testPanelVisibleInUIReturnsTrueForSelectedPanel() { + XCTAssertTrue( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: true, + isFocused: false + ) + ) + } + + func testPanelVisibleInUIReturnsTrueForFocusedPanelDuringTransientSelectionGap() { + XCTAssertTrue( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: false, + isFocused: true + ) + ) + } + + func testPanelVisibleInUIReturnsFalseWhenNeitherSelectedNorFocused() { + XCTAssertFalse( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: false, + isFocused: false + ) + ) + } +} diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift index d5464d73..1610dc34 100644 --- a/cmuxTests/WorkspaceManualUnreadTests.swift +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -1,4 +1,5 @@ import XCTest +import AppKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -106,3 +107,333 @@ final class WorkspaceManualUnreadTests: XCTestCase { ) } } + +final class CommandPaletteFuzzyMatcherTests: XCTestCase { + func testExactMatchScoresHigherThanPrefixAndContains() { + let exact = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab") + let prefix = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab now") + let contains = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "command rename tab flow") + + XCTAssertNotNil(exact) + XCTAssertNotNil(prefix) + XCTAssertNotNil(contains) + XCTAssertGreaterThan(exact ?? 0, prefix ?? 0) + XCTAssertGreaterThan(prefix ?? 0, contains ?? 0) + } + + func testInitialismMatchReturnsScore() { + let score = CommandPaletteFuzzyMatcher.score(query: "ocdi", candidate: "open current directory in ide") + XCTAssertNotNil(score) + XCTAssertGreaterThan(score ?? 0, 0) + } + + func testLongTokenLooseSubsequenceDoesNotMatch() { + let score = CommandPaletteFuzzyMatcher.score(query: "rename", candidate: "open current directory in ide") + XCTAssertNil(score) + } + + func testStitchedWordPrefixMatchesRetabForRenameTab() { + let score = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…") + XCTAssertNotNil(score) + XCTAssertGreaterThan(score ?? 0, 0) + } + + func testRetabPrefersRenameTabOverDistantTabWord() { + let renameTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…") + let reopenTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Reopen Closed Browser Tab") + + XCTAssertNotNil(renameTabScore) + XCTAssertNotNil(reopenTabScore) + XCTAssertGreaterThan(renameTabScore ?? 0, reopenTabScore ?? 0) + } + + func testRenameScoresHigherThanUnrelatedCommand() { + let renameScore = CommandPaletteFuzzyMatcher.score( + query: "rename", + candidates: ["Rename Tab…", "Tab • Terminal 1", "rename", "tab", "title"] + ) + let unrelatedScore = CommandPaletteFuzzyMatcher.score( + query: "rename", + candidates: [ + "Open Current Directory in IDE", + "Terminal • Terminal 1", + "terminal", + "directory", + "open", + "ide", + "code", + "default app" + ] + ) + + XCTAssertNotNil(renameScore) + XCTAssertNotNil(unrelatedScore) + XCTAssertGreaterThan(renameScore ?? 0, unrelatedScore ?? 0) + } + + func testTokenMatchingRequiresAllTokens() { + let match = CommandPaletteFuzzyMatcher.score( + query: "rename workspace", + candidates: ["Rename Workspace", "Workspace settings"] + ) + let miss = CommandPaletteFuzzyMatcher.score( + query: "rename workspace", + candidates: ["Rename Tab", "Tab settings"] + ) + + XCTAssertNotNil(match) + XCTAssertNil(miss) + } + + func testEmptyQueryReturnsZeroScore() { + let score = CommandPaletteFuzzyMatcher.score(query: " ", candidate: "anything") + XCTAssertEqual(score, 0) + } + + func testMatchCharacterIndicesForContainsMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "workspace", + candidate: "New Workspace" + ) + XCTAssertTrue(indices.contains(4)) + XCTAssertTrue(indices.contains(12)) + XCTAssertFalse(indices.contains(0)) + } + + func testMatchCharacterIndicesForSubsequenceMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "nws", + candidate: "New Workspace" + ) + XCTAssertTrue(indices.contains(0)) + XCTAssertTrue(indices.contains(2)) + XCTAssertTrue(indices.contains(8)) + } + + func testMatchCharacterIndicesForStitchedWordPrefixMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "retab", + candidate: "Rename Tab…" + ) + XCTAssertTrue(indices.contains(0)) + XCTAssertTrue(indices.contains(1)) + XCTAssertTrue(indices.contains(7)) + XCTAssertTrue(indices.contains(8)) + XCTAssertTrue(indices.contains(9)) + } +} + +final class CommandPaletteSwitcherSearchIndexerTests: XCTestCase { + func testKeywordsIncludeDirectoryBranchAndPortMetadata() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"], + branches: ["feature/cmd-palette-indexing"], + ports: [3000, 9222] + ) + + let keywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace", "switch"], + metadata: metadata + ) + + XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("3000")) + XCTAssertTrue(keywords.contains(":9222")) + } + + func testFuzzyMatcherMatchesDirectoryBranchAndPortMetadata() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/cmuxterm/worktrees/issue-123-switcher-search"], + branches: ["fix/switcher-metadata"], + ports: [4317] + ) + + let candidates = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata + ) + + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-search", candidates: candidates)) + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-metadata", candidates: candidates)) + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "4317", candidates: candidates)) + } + + func testWorkspaceDetailOmitsSplitDirectoryAndBranchTokens() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"], + branches: ["feature/cmd-palette-indexing"], + ports: [3000] + ) + + let keywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata, + detail: .workspace + ) + + XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("3000")) + XCTAssertFalse(keywords.contains("feat-cmd-palette")) + XCTAssertFalse(keywords.contains("cmd-palette-indexing")) + } + + func testSurfaceDetailOutranksWorkspaceDetailForPathToken() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/worktrees/cmux"], + branches: ["feature/cmd-palette"], + ports: [] + ) + + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata, + detail: .workspace + ) + let surfaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["surface"], + metadata: metadata, + detail: .surface + ) + + let workspaceScore = try XCTUnwrap( + CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: workspaceKeywords) + ) + let surfaceScore = try XCTUnwrap( + CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: surfaceKeywords) + ) + + XCTAssertGreaterThan( + surfaceScore, + workspaceScore, + "Surface rows should rank ahead of workspace rows for directory-token matches." + ) + } +} + +@MainActor +final class CommandPaletteRequestRoutingTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + } + + func testRequestedWindowTargetsOnlyMatchingObservedWindow() { + let windowA = makeWindow() + let windowB = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: windowA, + requestedWindow: windowA, + keyWindow: windowA, + mainWindow: windowA + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: windowB, + requestedWindow: windowA, + keyWindow: windowA, + mainWindow: windowA + ) + ) + } + + func testNilRequestedWindowFallsBackToKeyWindow() { + let key = makeWindow() + let other = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: key, + requestedWindow: nil, + keyWindow: key, + mainWindow: nil + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: other, + requestedWindow: nil, + keyWindow: key, + mainWindow: nil + ) + ) + } + + func testNilRequestedAndKeyFallsBackToMainWindow() { + let main = makeWindow() + let other = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: main, + requestedWindow: nil, + keyWindow: nil, + mainWindow: main + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: other, + requestedWindow: nil, + keyWindow: nil, + mainWindow: main + ) + ) + } + + func testNoObservedWindowNeverHandlesRequest() { + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: nil, + requestedWindow: makeWindow(), + keyWindow: makeWindow(), + mainWindow: makeWindow() + ) + ) + } +} + +final class CommandPaletteBackNavigationTests: XCTestCase { + func testBackspaceOnEmptyRenameInputReturnsToCommandList() { + XCTAssertTrue( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [] + ) + ) + } + + func testBackspaceWithRenameTextDoesNotReturnToCommandList() { + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "Terminal 1", + modifiers: [] + ) + ) + } + + func testModifiedBackspaceDoesNotReturnToCommandList() { + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [.control] + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [.command] + ) + ) + } +} diff --git a/cmuxUITests/AutomationSocketUITests.swift b/cmuxUITests/AutomationSocketUITests.swift index 8cc422a7..825207e5 100644 --- a/cmuxUITests/AutomationSocketUITests.swift +++ b/cmuxUITests/AutomationSocketUITests.swift @@ -6,6 +6,7 @@ final class AutomationSocketUITests: XCTestCase { private let defaultsDomain = "com.cmuxterm.app.debug" private let modeKey = "socketControlMode" private let legacyKey = "socketControlEnabled" + private let launchTag = "ui-tests-automation-socket" override func setUp() { super.setUp() @@ -16,11 +17,12 @@ final class AutomationSocketUITests: XCTestCase { } func testSocketToggleDisablesAndEnables() { - let app = XCUIApplication() - app.launchArguments += ["-\(modeKey)", "cmuxOnly"] - app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + let app = configuredApp(mode: "cmuxOnly") app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for socket toggle test. state=\(app.state.rawValue)" + ) guard let resolvedPath = resolveSocketPath(timeout: 5.0) else { XCTFail("Expected control socket to exist") @@ -32,16 +34,40 @@ final class AutomationSocketUITests: XCTestCase { } func testSocketDisabledWhenSettingOff() { - let app = XCUIApplication() - app.launchArguments += ["-\(modeKey)", "off"] - app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + let app = configuredApp(mode: "off") app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for socket off test. state=\(app.state.rawValue)" + ) XCTAssertTrue(waitForSocket(exists: false, timeout: 3.0)) app.terminate() } + private func configuredApp(mode: String) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += ["-\(modeKey)", mode] + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + // Debug launches require a tag outside reload.sh; provide one in UITests so CI + // does not fail with "Application ... does not have a process ID". + app.launchEnvironment["CMUX_TAG"] = launchTag + return app + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + // On busy UI runners the app can launch backgrounded; activate once before failing. + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { diff --git a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift index 4d18e5dc..01b045c3 100644 --- a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +++ b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift @@ -18,7 +18,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { } func testOmnibarSuggestionsAlignToPillAndCmdNP() { - seedBrowserHistoryForTest() + seedBrowserHistoryForTest(seedEntries: [ + SeedEntry(url: "https://example.com/", title: "Example Domain", visitCount: 12, typedCount: 4), + SeedEntry(url: "https://example.org/", title: "Example Organization", visitCount: 9, typedCount: 3), + SeedEntry(url: "https://go.dev/", title: "The Go Programming Language", visitCount: 6, typedCount: 1), + ]) let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" @@ -26,8 +30,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath // Keep suggestions deterministic for the keyboard-nav assertions. app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) // Focus omnibar. app.typeKey("l", modifierFlags: [.command]) @@ -39,7 +42,10 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) // Type a query that matches the seeded URL. - omnibar.typeText("exam") + XCTAssertTrue( + typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "exam", timeout: 6.0), + "Expected omnibar suggestions to appear for 'exam'" + ) // SwiftUI's accessibility typing for ScrollView can vary; match by identifier regardless of element type. let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch @@ -75,10 +81,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(row1.waitForExistence(timeout: 6.0)) app.typeKey("n", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1. value=\(String(describing: row1.value))") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row1, timeout: 3.0), + "Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))" + ) app.typeKey("p", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row0, timeout: 2.0), "Expected Cmd+P to return to row 0. value=\(String(describing: row0.value))") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row0, timeout: 3.0), + "Expected Cmd+P to move selection back to row 0. row0Value=\(String(describing: row0.value))" + ) app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) @@ -104,14 +116,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath // Keep suggestions deterministic. app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) + XCTAssertTrue( + focusOmnibarWithCmdL(app: app, omnibar: omnibar, timeout: 4.0), + "Expected Cmd+L to place keyboard focus in omnibar before typing" + ) // Focus omnibar and navigate to example.com via autocompletion (row 0). - app.typeKey("l", modifierFlags: [.command]) omnibar.typeText("exam") let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch @@ -189,14 +203,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"# - app.launch() - app.activate() - - app.typeKey("l", modifierFlags: [.command]) + launchAndEnsureForeground(app) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) - omnibar.typeText("go") + XCTAssertTrue( + typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "go", timeout: 6.0), + "Expected omnibar suggestions to appear for 'go'" + ) let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0)) @@ -207,13 +221,22 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(row2.waitForExistence(timeout: 6.0)) app.typeKey("n", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row1, timeout: 3.0), + "Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))" + ) app.typeKey("n", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row2, timeout: 2.0), "Expected repeated Cmd+N to keep moving selection") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row2, timeout: 3.0), + "Expected repeated Cmd+N to move selection to row 2. row2Value=\(String(describing: row2.value))" + ) app.typeKey("p", modifierFlags: [.command]) - XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+P to move selection up") + XCTAssertTrue( + waitForSuggestionRowToBeSelected(row1, timeout: 3.0), + "Expected Cmd+P to move selection back to row 1. row1Value=\(String(describing: row1.value))" + ) } func testOmnibarShowsMultipleRowsWithoutClipping() { @@ -225,8 +248,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"# - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -253,8 +275,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -315,8 +336,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) @@ -342,7 +362,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey("l", modifierFlags: [.command]) app.typeText("lo") - let typedDeadline = Date().addingTimeInterval(4.0) + let typedDeadline = Date().addingTimeInterval(7.0) var observedValue = "" var startsWithTypedPrefix = false while Date() < typedDeadline { @@ -373,8 +393,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -385,14 +404,46 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0)) - let row0 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.0").firstMatch - XCTAssertTrue(row0.waitForExistence(timeout: 4.0)) - let row0Value = (row0.value as? String) ?? "" - XCTAssertTrue( - row0Value.localizedCaseInsensitiveContains("gmail"), - "Expected autocomplete candidate to be first row. row0Value=\(row0Value)" - ) + let rows: [XCUIElement] = (0...4).map { + app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.\($0)").firstMatch + } + XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0)) + + var gmailRowIndex: Int? + let gmailDeadline = Date().addingTimeInterval(4.0) + while Date() < gmailDeadline { + for (index, row) in rows.enumerated() where row.exists { + let rowValue = (row.value as? String) ?? "" + if rowValue.localizedCaseInsensitiveContains("gmail") { + gmailRowIndex = index + break + } + } + if gmailRowIndex != nil { + break + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + guard let gmailRowIndex else { + let rowValues = rows.enumerated().compactMap { index, row -> String? in + guard row.exists else { return nil } + return "row\(index)=\((row.value as? String) ?? "<nil>")" + }.joined(separator: ", ") + XCTFail("Expected a Gmail suggestion row. rows=\(rowValues)") + return + } + + if gmailRowIndex > 0 { + let gmailRow = rows[gmailRowIndex] + for _ in 0..<gmailRowIndex { + app.typeKey("n", modifierFlags: [.command]) + } + XCTAssertTrue( + waitForSuggestionRowToBeSelected(gmailRow, timeout: 3.0), + "Expected Cmd+N to select Gmail row \(gmailRowIndex). value=\(String(describing: gmailRow.value))" + ) + } app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) @@ -415,8 +466,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -461,8 +511,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) @@ -499,26 +548,28 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) app.typeKey("l", modifierFlags: [.command]) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) - omnibar.typeText("go") + omnibar.typeText("exam") + let typedPrefix = "exam" let inlineDeadline = Date().addingTimeInterval(3.0) + var valueBeforeCmdA = "" while Date() < inlineDeadline { - let value = (omnibar.value as? String) ?? "" - if value.contains("google.com") { + valueBeforeCmdA = (omnibar.value as? String) ?? "" + let normalized = valueBeforeCmdA.lowercased() + if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count { break } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } XCTAssertTrue( - ((omnibar.value as? String) ?? "").contains("google.com"), - "Expected inline completion to show google.com before Cmd+A." + valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count, + "Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)" ) app.typeKey("a", modifierFlags: [.command]) @@ -526,11 +577,30 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { let afterCmdA = (omnibar.value as? String) ?? "" XCTAssertTrue( - afterCmdA.contains("google.com"), - "Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. value=\(afterCmdA)" + afterCmdA.lowercased().hasPrefix(typedPrefix) && afterCmdA.utf16.count > typedPrefix.utf16.count, + "Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. before=\(valueBeforeCmdA) after=\(afterCmdA)" ) } + private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: timeout), + "Expected app to launch in foreground. state=\(app.state.rawValue)" + ) + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + private struct SeedEntry { let url: String let title: String @@ -617,14 +687,81 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { add(attachment) } - private func waitForRowSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool { + private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - if ((row.value as? String) ?? "").contains("selected") { + if isSuggestionRowSelected(row) { return true } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } - return ((row.value as? String) ?? "").contains("selected") + return isSuggestionRowSelected(row) + } + + private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool { + guard row.exists else { return false } + guard let rawValue = row.value as? String else { return false } + return rawValue.localizedCaseInsensitiveContains("selected") + } + + private func typeQueryAndWaitForSuggestions( + app: XCUIApplication, + omnibar: XCUIElement, + query: String, + timeout: TimeInterval, + attempts: Int = 3 + ) -> Bool { + let suggestions = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch + for _ in 0..<attempts { + if app.state == .runningBackground { + app.activate() + _ = app.wait(for: .runningForeground, timeout: 2.0) + } + app.typeKey("l", modifierFlags: [.command]) + guard omnibar.waitForExistence(timeout: 6.0) else { continue } + omnibar.click() + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + omnibar.click() + omnibar.typeText(query) + if suggestions.waitForExistence(timeout: timeout) { + return true + } + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + return suggestions.exists + } + + private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + app.typeKey("l", modifierFlags: [.command]) + guard omnibar.waitForExistence(timeout: 1.0) else { continue } + + let before = (omnibar.value as? String) ?? "" + omnibar.typeText("z") + + let probeDeadline = Date().addingTimeInterval(0.5) + var acceptedProbe = false + while Date() < probeDeadline { + let value = (omnibar.value as? String) ?? "" + if value != before { + acceptedProbe = true + break + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + if acceptedProbe { + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + return true + } + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return false } } diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index 6027832a..e024151c 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -18,9 +18,9 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath - app.launch() - app.activate() + launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused"], timeout: 10.0), @@ -93,10 +93,10 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_USE_GHOSTTY_CONFIG"] = "1" - app.launch() - app.activate() + launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused", "ghosttyGotoSplitLeftShortcut"], timeout: 10.0), @@ -109,7 +109,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") - XCTAssertEqual(setup["ghosttyGotoSplitLeftShortcut"], "⌃⌘H", "Expected Ghostty config trigger to be Cmd+Ctrl+H") + XCTAssertFalse((setup["ghosttyGotoSplitLeftShortcut"] ?? "").isEmpty, "Expected Ghostty trigger metadata to be present") guard let expectedTerminalPaneId = setup["terminalPaneId"] else { XCTFail("Missing terminalPaneId in goto_split setup data") @@ -132,8 +132,8 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath - app.launch() - app.activate() + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0), @@ -171,13 +171,161 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testEscapeRestoresFocusedPageInputAfterCmdL() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "webViewFocused", + "webInputFocusSeeded", + "webInputFocusElementId", + "webInputFocusSecondaryElementId", + "webInputFocusSecondaryClickOffsetX", + "webInputFocusSecondaryClickOffsetY" + ], + timeout: 12.0 + ), + "Expected setup data including focused page input to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before Cmd+L") + + guard let expectedInputId = setup["webInputFocusElementId"], !expectedInputId.isEmpty else { + XCTFail("Missing webInputFocusElementId in setup data") + return + } + guard let expectedSecondaryInputId = setup["webInputFocusSecondaryElementId"], !expectedSecondaryInputId.isEmpty else { + XCTFail("Missing webInputFocusSecondaryElementId in setup data") + return + } + guard let secondaryClickOffsetXRaw = setup["webInputFocusSecondaryClickOffsetX"], + let secondaryClickOffsetYRaw = setup["webInputFocusSecondaryClickOffsetY"], + let secondaryClickOffsetX = Double(secondaryClickOffsetXRaw), + let secondaryClickOffsetY = Double(secondaryClickOffsetYRaw) else { + XCTFail( + "Missing or invalid secondary input click offsets in setup data. " + + "webInputFocusSecondaryClickOffsetX=\(setup["webInputFocusSecondaryClickOffsetX"] ?? "nil") " + + "webInputFocusSecondaryClickOffsetY=\(setup["webInputFocusSecondaryClickOffsetY"] ?? "nil")" + ) + return + } + + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" + }, + "Expected Cmd+L to focus omnibar" + ) + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + if !waitForDataMatch(timeout: 2.0, predicate: { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedInputId && + data["addressBarExitActiveElementEditable"] == "true" + }) { + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + } + + let restoredExpectedInput = waitForDataMatch(timeout: 6.0) { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedInputId && + data["addressBarExitActiveElementEditable"] == "true" + } + if !restoredExpectedInput { + let snapshot = loadData() ?? [:] + XCTFail( + "Expected Escape to restore focus to the previously focused page input. " + + "expectedInputId=\(expectedInputId) " + + "webViewFocusedAfterAddressBarExit=\(snapshot["webViewFocusedAfterAddressBarExit"] ?? "nil") " + + "addressBarExitActiveElementId=\(snapshot["addressBarExitActiveElementId"] ?? "nil") " + + "addressBarExitActiveElementTag=\(snapshot["addressBarExitActiveElementTag"] ?? "nil") " + + "addressBarExitActiveElementType=\(snapshot["addressBarExitActiveElementType"] ?? "nil") " + + "addressBarExitActiveElementEditable=\(snapshot["addressBarExitActiveElementEditable"] ?? "nil") " + + "addressBarExitTrackedFocusStateId=\(snapshot["addressBarExitTrackedFocusStateId"] ?? "nil") " + + "addressBarExitFocusTrackerInstalled=\(snapshot["addressBarExitFocusTrackerInstalled"] ?? "nil") " + + "addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " + + "addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " + + "addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil") " + + "webInputFocusElementId=\(snapshot["webInputFocusElementId"] ?? "nil") " + + "webInputFocusTrackerInstalled=\(snapshot["webInputFocusTrackerInstalled"] ?? "nil") " + + "webInputFocusTrackedStateId=\(snapshot["webInputFocusTrackedStateId"] ?? "nil")" + ) + } + + let window = app.windows.firstMatch + XCTAssertTrue( + window.waitForExistence(timeout: 6.0), + "Expected app window for post-escape click regression check" + ) + + RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: secondaryClickOffsetX, dy: secondaryClickOffsetY)) + .click() + RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + + app.typeKey("l", modifierFlags: [.command]) + let clickMovedFocusToSecondary = waitForDataMatch(timeout: 6.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" && + data["addressBarFocusActiveElementId"] == expectedSecondaryInputId && + data["addressBarFocusActiveElementEditable"] == "true" + } + if !clickMovedFocusToSecondary { + let snapshot = loadData() ?? [:] + XCTFail( + "Expected post-escape click to focus secondary page input before Cmd+L. " + + "secondaryInputId=\(expectedSecondaryInputId) " + + "addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " + + "addressBarFocusActiveElementTag=\(snapshot["addressBarFocusActiveElementTag"] ?? "nil") " + + "addressBarFocusActiveElementType=\(snapshot["addressBarFocusActiveElementType"] ?? "nil") " + + "addressBarFocusActiveElementEditable=\(snapshot["addressBarFocusActiveElementEditable"] ?? "nil") " + + "addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " + + "addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil")" + ) + } + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + if !waitForDataMatch(timeout: 2.0, predicate: { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedSecondaryInputId && + data["addressBarExitActiveElementEditable"] == "true" + }) { + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + } + + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedSecondaryInputId && + data["addressBarExitActiveElementEditable"] == "true" + }, + "Expected Escape to restore focus to the clicked secondary page input" + ) + } + func testCmdLOpensBrowserWhenTerminalFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath - app.launch() - app.activate() + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0), @@ -225,8 +373,8 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath - app.launch() - app.activate() + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0), @@ -275,13 +423,77 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testClickingBrowserDismissesCommandPaletteAndKeepsBrowserFocus() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0), + "Expected goto_split setup data to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + guard let expectedBrowserPanelId = setup["browserPanelId"] else { + XCTFail("Missing browserPanelId in goto_split setup data") + return + } + + guard let expectedTerminalPaneId = setup["terminalPaneId"] else { + XCTFail("Missing terminalPaneId in goto_split setup data") + return + } + + // Move focus away from browser to terminal first so Cmd+R opens the rename overlay. + app.typeKey("h", modifierFlags: [.command, .control]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["lastMoveDirection"] == "left" && data["focusedPaneId"] == expectedTerminalPaneId + }, + "Expected Cmd+Ctrl+H to move focus to left pane (terminal)" + ) + + let renameField = app.textFields["CommandPaletteRenameField"].firstMatch + app.typeKey("r", modifierFlags: [.command]) + XCTAssertTrue( + renameField.waitForExistence(timeout: 5.0), + "Expected Cmd+R to open the rename command palette while terminal is focused" + ) + + let browserPane = app.otherElements["BrowserPanelContent.\(expectedBrowserPanelId)"].firstMatch + XCTAssertTrue(browserPane.waitForExistence(timeout: 5.0), "Expected browser pane content for click target") + browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() + XCTAssertTrue( + waitForNonExistence(renameField, timeout: 5.0), + "Expected clicking the browser pane to dismiss the command palette" + ) + + // Cmd+L behavior is context-aware: + // - If terminal is still focused: opens a new browser in that pane. + // - If the original browser took focus: focuses that existing browser's omnibar. + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + guard data["webViewFocusedAfterAddressBarFocus"] == "false" else { return false } + return data["webViewFocusedAfterAddressBarFocusPanelId"] == expectedBrowserPanelId + }, + "Expected clicking browser content to dismiss the palette and keep focus on the existing browser pane" + ) + } + func testCmdDSplitsRightWhenWebViewFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath - app.launch() - app.activate() + launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0), @@ -314,8 +526,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath - app.launch() - app.activate() + launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0), @@ -343,13 +554,156 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testCmdShiftEnterKeepsBrowserOmnibarHittableAcrossZoomRoundTripWhenWebViewFocused() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0), + "Expected goto_split setup data to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + guard let browserPanelId = setup["browserPanelId"] else { + XCTFail("Missing browserPanelId in goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + let pill = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarPill").firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field before zoom") + XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill before zoom") + + // Reproduce the loaded-page state from the bug report before toggling zoom. + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue(waitForElementToBecomeHittable(pill, timeout: 6.0), "Expected browser omnibar pill before navigation") + pill.click() + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + app.typeText(zoomRoundTripPageURL) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0), + "Expected browser to finish navigating to the regression page before zoom. value=\(String(describing: omnibar.value))" + ) + + let browserPane = app.otherElements["BrowserPanelContent.\(browserPanelId)"].firstMatch + XCTAssertTrue(browserPane.waitForExistence(timeout: 6.0), "Expected browser pane content before zoom") + browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift]) + XCTAssertTrue( + waitForDataMatch(timeout: 8.0) { data in + data["splitZoomedAfterToggle"] == "true" && + data["otherTerminalHostHiddenAfterToggle"] == "true" && + data["otherTerminalVisibleFlagAfterToggle"] == "false" + }, + "Expected Cmd+Shift+Enter zoom-in to hide the non-browser terminal portal. data=\(loadData() ?? [:])" + ) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift]) + XCTAssertTrue( + waitForDataMatch(timeout: 8.0) { data in + data["splitZoomedAfterToggle"] == "false" && + data["otherTerminalHostHiddenAfterToggle"] == "false" && + data["otherTerminalVisibleFlagAfterToggle"] == "true" + }, + "Expected Cmd+Shift+Enter zoom-out to restore the non-browser terminal portal. data=\(loadData() ?? [:])" + ) + + XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field after Cmd+Shift+Enter zoom round-trip") + XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill after Cmd+Shift+Enter zoom round-trip") + XCTAssertTrue( + waitForElementToBecomeHittable(pill, timeout: 6.0), + "Expected browser omnibar to stay hittable after Cmd+Shift+Enter zoom round-trip" + ) + let page = app.webViews.firstMatch + XCTAssertTrue(page.waitForExistence(timeout: 6.0), "Expected browser web area after Cmd+Shift+Enter") + XCTAssertLessThanOrEqual( + pill.frame.maxY, + page.frame.minY + 12, + "Expected browser omnibar to remain above the web content after Cmd+Shift+Enter. pill=\(pill.frame) page=\(page.frame)" + ) + + pill.click() + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + app.typeText("issue1144") + + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "issue1144", timeout: 4.0), + "Expected browser omnibar to stay editable after Cmd+Shift+Enter. value=\(String(describing: omnibar.value))" + ) + } + + func testCmdShiftEnterHidesBrowserPortalWhenTerminalPaneZooms() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData(keys: ["terminalPaneId", "browserPanelId", "webViewFocused"], timeout: 10.0), + "Expected goto_split setup data to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + guard let expectedTerminalPaneId = setup["terminalPaneId"] else { + XCTFail("Missing terminalPaneId in goto_split setup data") + return + } + + app.typeKey("h", modifierFlags: [.command, .control]) + + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["focusedPaneId"] == expectedTerminalPaneId && data["focusedPanelKind"] == "terminal" + }, + "Expected Cmd+Ctrl+H to focus the terminal pane before zoom. data=\(loadData() ?? [:])" + ) + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift]) + XCTAssertTrue( + waitForDataMatch(timeout: 8.0) { data in + data["splitZoomedAfterToggle"] == "true" && + data["browserContainerHiddenAfterToggle"] == "true" && + data["browserVisibleFlagAfterToggle"] == "false" + }, + "Expected Cmd+Shift+Enter zoom-in on the terminal pane to hide the browser portal. data=\(loadData() ?? [:])" + ) + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift]) + XCTAssertTrue( + waitForDataMatch(timeout: 8.0) { data in + data["splitZoomedAfterToggle"] == "false" && + data["browserContainerHiddenAfterToggle"] == "false" && + data["browserVisibleFlagAfterToggle"] == "true" + }, + "Expected Cmd+Shift+Enter zoom-out from the terminal pane to restore the browser portal. data=\(loadData() ?? [:])" + ) + } + func testCmdDSplitsRightWhenOmnibarFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath - app.launch() - app.activate() + launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0), @@ -390,8 +744,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath - app.launch() - app.activate() + launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0), @@ -427,6 +780,214 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testCmdOptionPaneSwitchPreservesFindFieldFocus() { + runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: false) + } + + func testCmdCtrlPaneSwitchPreservesFindFieldFocus() { + runFindFocusPersistenceScenario(route: .cmdCtrlLetters, useAutofocusRacePage: false) + } + + func testCmdOptionPaneSwitchPreservesFindFieldFocusDuringPageAutofocusRace() { + runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: true) + } + + private enum FindFocusRoute { + case cmdOptionArrows + case cmdCtrlLetters + } + + private func runFindFocusPersistenceScenario(route: FindFocusRoute, useAutofocusRacePage: Bool) { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + if route == .cmdCtrlLetters { + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + } + launchAndEnsureForeground(app) + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10.0), "Expected main window to exist") + + // Repro setup: split, open browser split, navigate to example.com. + app.typeKey("d", modifierFlags: [.command]) + focusRightPaneForFindScenario(app, route: route) + + app.typeKey("l", modifierFlags: [.command, .shift]) + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+Shift+L") + + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + if useAutofocusRacePage { + app.typeText(autofocusRacePageURL) + } else { + app.typeText("example.com") + } + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + if useAutofocusRacePage { + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0), + "Expected browser navigation to data URL before running find flow. value=\(String(describing: omnibar.value))" + ) + } else { + XCTAssertTrue( + waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0), + "Expected browser navigation to example domain before running find flow. value=\(String(describing: omnibar.value))" + ) + } + + // Left terminal: Cmd+F then type "la". + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "terminal" + }, + "Expected left terminal pane to be focused before terminal find. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("la") + + // Right browser: Cmd+F then type "am". + focusRightPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "right" + && data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "la" + }, + "Expected terminal find query to persist as 'la' after focusing browser pane. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("am") + + if useAutofocusRacePage { + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "#focused", timeout: 5.0), + "Expected autofocus race page to signal focus handoff via URL hash. value=\(String(describing: omnibar.value))" + ) + } + + // Left terminal: typing should keep going into terminal find field. + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "left" + && data["focusedPanelKind"] == "terminal" + && data["browserFindNeedle"] == "am" + }, + "Expected browser find query to persist as 'am' after returning left. data=\(String(describing: loadData()))" + ) + app.typeText("foo") + + // Right browser: typing should keep going into browser find field. + focusRightPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "right" + && data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "lafoo" + }, + "Expected terminal find query to stay focused and become 'lafoo'. data=\(String(describing: loadData()))" + ) + app.typeText("do") + + // Move left once more so the recorder captures browser find state after typing. + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "left" + && data["focusedPanelKind"] == "terminal" + && data["browserFindNeedle"] == "amdo" + }, + "Expected browser find query to stay focused and become 'amdo'. data=\(String(describing: loadData()))" + ) + } + + private func focusLeftPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { + switch route { + case .cmdOptionArrows: + app.typeKey(XCUIKeyboardKey.leftArrow.rawValue, modifierFlags: [.command, .option]) + case .cmdCtrlLetters: + app.typeKey("h", modifierFlags: [.command, .control]) + } + } + + private func focusRightPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { + switch route { + case .cmdOptionArrows: + app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: [.command, .option]) + case .cmdCtrlLetters: + app.typeKey("l", modifierFlags: [.command, .control]) + } + } + + private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let value = (omnibar.value as? String) ?? "" + if value.contains("example.com") || value.contains("example.org") { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + let value = (omnibar.value as? String) ?? "" + return value.contains("example.com") || value.contains("example.org") + } + + private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let value = (omnibar.value as? String) ?? "" + if value.contains(expectedSubstring) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + let value = (omnibar.value as? String) ?? "" + return value.contains(expectedSubstring) + } + + private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if element.exists && element.isHittable { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return element.exists && element.isHittable + } + + private var autofocusRacePageURL: String { + "data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E" + } + + private var zoomRoundTripPageURL: String { + "data:text/html,%3Ctitle%3EIssue%201144%3C/title%3E%3Cbody%20style%3D%22margin:0;background:%231d1f24;color:white;font-family:system-ui;height:2200px%22%3E%3Cmain%20style%3D%22padding:32px%22%3E%3Ch1%3EIssue%201144%20Regression%20Page%3C/h1%3E%3Cp%3EZoom%20should%20not%20leave%20stale%20split%20chrome%20above%20the%20browser%20omnibar.%3C/p%3E%3C/main%3E%3C/body%3E" + } + + private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: timeout), + "Expected app to launch in foreground. state=\(app.state.rawValue)" + ) + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { @@ -455,6 +1016,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { return false } + private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + private func loadData() -> [String: String]? { guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else { return nil diff --git a/cmuxUITests/CloseWindowConfirmDialogUITests.swift b/cmuxUITests/CloseWindowConfirmDialogUITests.swift new file mode 100644 index 00000000..f64078d4 --- /dev/null +++ b/cmuxUITests/CloseWindowConfirmDialogUITests.swift @@ -0,0 +1,138 @@ +import XCTest + +final class CloseWindowConfirmDialogUITests: XCTestCase { + private let launchTag = "ui-tests-close-window-confirm" + + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testCmdCtrlWShowsCloseWindowConfirmationText() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_TAG"] = launchTag + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for close-window confirmation test. state=\(app.state.rawValue)" + ) + + app.typeKey("w", modifierFlags: [.command, .control]) + + XCTAssertTrue( + waitForCloseWindowAlert(app: app, timeout: 5.0), + "Expected Cmd+Ctrl+W to show the close window confirmation alert" + ) + + clickCancelOnCloseWindowAlert(app: app) + + XCTAssertFalse( + isCloseWindowAlertPresent(app: app), + "Expected close window confirmation alert to dismiss after clicking Cancel" + ) + XCTAssertTrue(app.windows.firstMatch.exists, "Expected the window to remain open after cancelling close") + } + + func testReturnConfirmsCloseWindowDialog() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_TAG"] = launchTag + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for close-window confirmation test. state=\(app.state.rawValue)" + ) + + app.typeKey("w", modifierFlags: [.command, .control]) + + XCTAssertTrue( + waitForCloseWindowAlert(app: app, timeout: 5.0), + "Expected Cmd+Ctrl+W to show the close window confirmation alert" + ) + + app.typeKey(.return, modifierFlags: []) + + XCTAssertTrue( + waitForCloseWindowAlertToDismiss(app: app, timeout: 5.0), + "Expected Return to dismiss the close window confirmation alert" + ) + XCTAssertTrue( + waitForMainWindowToClose(app: app, timeout: 5.0), + "Expected Return to confirm window close" + ) + } + + private func isCloseWindowAlertPresent(app: XCUIApplication) -> Bool { + if closeWindowDialog(app: app).exists { return true } + if closeWindowAlert(app: app).exists { return true } + return app.staticTexts["Close window?"].exists + } + + private func waitForCloseWindowAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if isCloseWindowAlertPresent(app: app) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return isCloseWindowAlertPresent(app: app) + } + + private func waitForCloseWindowAlertToDismiss(app: XCUIApplication, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !isCloseWindowAlertPresent(app: app) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return !isCloseWindowAlertPresent(app: app) + } + + private func waitForMainWindowToClose(app: XCUIApplication, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !app.windows.firstMatch.exists { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return !app.windows.firstMatch.exists + } + + private func clickCancelOnCloseWindowAlert(app: XCUIApplication) { + let dialog = closeWindowDialog(app: app) + if dialog.exists { + dialog.buttons["Cancel"].firstMatch.click() + return + } + let alert = closeWindowAlert(app: app) + if alert.exists { + alert.buttons["Cancel"].firstMatch.click() + return + } + let anyDialog = app.dialogs.firstMatch + if anyDialog.exists, anyDialog.buttons["Cancel"].exists { + anyDialog.buttons["Cancel"].firstMatch.click() + } + } + + private func closeWindowDialog(app: XCUIApplication) -> XCUIElement { + app.dialogs.containing(.staticText, identifier: "Close window?").firstMatch + } + + private func closeWindowAlert(app: XCUIApplication) -> XCUIElement { + app.alerts.containing(.staticText, identifier: "Close window?").firstMatch + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } +} diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index 02ec9239..54c35d19 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -134,8 +134,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } let rightPanelId = ready["rightPanelId"] ?? "" - XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be the focused panel before Ctrl+D. data=\(ready)") - XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected AppKit first responder to match right split before Ctrl+D. data=\(ready)") + guard !rightPanelId.isEmpty else { + XCTFail("Missing rightPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Horizontal split") // Exercise the real keyboard path (same path as user typing Ctrl+D), not an in-process helper. app.activate() @@ -191,8 +194,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } let rightPanelId = ready["rightPanelId"] ?? "" - XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be focused before Ctrl+D. data=\(ready)") - XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected first responder to match right split before Ctrl+D. data=\(ready)") + guard !rightPanelId.isEmpty else { + XCTFail("Missing rightPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Three-pane layout") guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else { XCTFail("Timed out waiting for done=1 after Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])") return @@ -257,16 +263,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { 2, "Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)" ) - XCTAssertEqual( - ready["focusedPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)" - ) - XCTAssertEqual( - ready["firstResponderPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)" - ) + guard !exitPanelId.isEmpty else { + XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close") app.typeKey("d", modifierFlags: [.control]) @@ -335,16 +336,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { 2, "Attempt \(attempt): expected two panels before Ctrl+D in 2x2-bottom-close repro. data=\(ready)" ) - XCTAssertEqual( - ready["focusedPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)" - ) - XCTAssertEqual( - ready["firstResponderPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)" - ) + guard !exitPanelId.isEmpty else { + XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-bottom-close") app.typeKey("d", modifierFlags: [.control]) @@ -412,16 +408,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { 2, "Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)" ) - XCTAssertEqual( - ready["focusedPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)" - ) - XCTAssertEqual( - ready["firstResponderPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)" - ) + guard !exitPanelId.isEmpty else { + XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close real key") app.typeKey("d", modifierFlags: [.control]) @@ -497,16 +488,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { 2, "Attempt \(attempt): expected two panels before Ctrl+D in left/right repro. data=\(ready)" ) - XCTAssertEqual( - ready["focusedPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)" - ) - XCTAssertEqual( - ready["firstResponderPanelBefore"], - exitPanelId, - "Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)" - ) + guard !exitPanelId.isEmpty else { + XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)") + return + } + assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): left/right real key") app.typeKey("d", modifierFlags: [.control]) @@ -546,6 +532,68 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } } + func testCtrlDEarlyDuringSplitStartupKeepsWindowOpen() { + let attempts = 12 + for attempt in 1...attempts { + let app = XCUIApplication() + let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-ctrl-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: dataPath) + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] = "lr" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_d" + app.launch() + app.activate() + defer { app.terminate() } + + XCTAssertTrue( + waitForAnyJSON(atPath: dataPath, timeout: 12.0), + "Attempt \(attempt): expected early Ctrl+D setup data at \(dataPath)" + ) + guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else { + XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = done["setupError"], !setupError.isEmpty { + XCTFail("Attempt \(attempt): setup failed: \(setupError)") + return + } + + let workspaceCountAfter = Int(done["workspaceCountAfter"] ?? "") ?? -1 + let panelCountAfter = Int(done["panelCountAfter"] ?? "") ?? -1 + let closedWorkspace = (done["closedWorkspace"] ?? "") == "1" + let timedOut = (done["timedOut"] ?? "") == "1" + let triggerMode = done["autoTriggerMode"] ?? "" + let exitPanelId = done["exitPanelId"] ?? "" + let workspaceId = done["workspaceId"] ?? "" + let probeSurfaceId = done["probeShowChildExitedSurfaceId"] ?? "" + let probeTabId = done["probeShowChildExitedTabId"] ?? "" + + XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+D timed out. data=\(done)") + XCTAssertEqual(triggerMode, "strict_early_ctrl_d", "Attempt \(attempt): expected strict early Ctrl+D trigger mode. data=\(done)") + XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+D. data=\(done)") + XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+D. data=\(done)") + XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+D. data=\(done)") + if let showChildExitedCount = Int(done["probeShowChildExitedCount"] ?? "") { + XCTAssertEqual(showChildExitedCount, 1, "Attempt \(attempt): expected exactly one SHOW_CHILD_EXITED callback for one early Ctrl+D. data=\(done)") + } + if !exitPanelId.isEmpty, !probeSurfaceId.isEmpty { + XCTAssertEqual(probeSurfaceId, exitPanelId, "Attempt \(attempt): SHOW_CHILD_EXITED should target the split opened by Cmd+D. data=\(done)") + } + if !workspaceId.isEmpty, !probeTabId.isEmpty { + XCTAssertEqual(probeTabId, workspaceId, "Attempt \(attempt): SHOW_CHILD_EXITED should resolve to the active workspace. data=\(done)") + } + XCTAssertTrue( + waitForWindowCount(app: app, atLeast: 1, timeout: 2.0), + "Attempt \(attempt): app window should remain open after early Ctrl+D. data=\(done)" + ) + } + } + private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { @@ -619,6 +667,26 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { return nil } + private func assertCtrlDPreconditionsBeforeTrigger( + _ data: [String: String], + expectedExitPanelId: String, + context: String + ) { + XCTAssertEqual( + data["focusedPanelBefore"], + expectedExitPanelId, + "\(context): expected target exit pane to be focused before Ctrl+D. data=\(data)" + ) + let firstResponderPanelBefore = data["firstResponderPanelBefore"] ?? "" + if !firstResponderPanelBefore.isEmpty { + XCTAssertEqual( + firstResponderPanelBefore, + expectedExitPanelId, + "\(context): expected first responder to match target pane before Ctrl+D when present. data=\(data)" + ) + } + } + private func loadJSON(atPath path: String) -> [String: String]? { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 501d38f6..d6f282a9 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -5,12 +5,14 @@ import CoreGraphics final class MultiWindowNotificationsUITests: XCTestCase { private var dataPath = "" private var socketPath = "" + private var launchTag = "" override func setUp() { super.setUp() continueAfterFailure = false dataPath = "/tmp/cmux-ui-test-multi-window-notifs-\(UUID().uuidString).json" socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock" + launchTag = "ui-tests-multi-window-notifs-\(UUID().uuidString.prefix(8))" try? FileManager.default.removeItem(atPath: dataPath) try? FileManager.default.removeItem(atPath: socketPath) } @@ -25,8 +27,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath + app.launchEnvironment["CMUX_TAG"] = launchTag app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for multi-window routing test. state=\(app.state.rawValue)" + ) XCTAssertTrue( waitForData(keys: [ @@ -108,8 +114,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath + app.launchEnvironment["CMUX_TAG"] = launchTag app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for notifications popover shortcut test. state=\(app.state.rawValue)" + ) XCTAssertTrue( waitForData(keys: ["notifId1"], timeout: 15.0), @@ -137,19 +147,65 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTAssertTrue(waitForElementToDisappear(targetButton, timeout: 3.0), "Expected popover to close on Escape") } - func testEmptyNotificationsPopoverBlocksTerminalTyping() { + func testNotificationsPopoverJumpToLatestButtonShowsShortcut() { let app = XCUIApplication() - app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath + app.launchEnvironment["CMUX_TAG"] = launchTag app.launch() - app.activate() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for jump-to-latest popover test. state=\(app.state.rawValue)" + ) + + XCTAssertTrue(waitForData(keys: ["notifId1"], timeout: 15.0), "Expected multi-window notification setup data") + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) + + app.typeKey("i", modifierFlags: [.command]) + + let jumpButton = app.buttons["notificationsPopover.jumpToLatest"] + XCTAssertTrue(jumpButton.waitForExistence(timeout: 6.0), "Expected Jump to Latest button in notifications popover") + let shortcutValue = jumpButton.value as? String + XCTAssertNotNil(shortcutValue, "Expected Jump to Latest shortcut badge") + XCTAssertTrue(shortcutValue?.contains("⌘") == true, "Expected Jump to Latest shortcut to include Command") + XCTAssertTrue(shortcutValue?.contains("⇧") == true, "Expected Jump to Latest shortcut to include Shift") + XCTAssertTrue(shortcutValue?.uppercased().contains("U") == true, "Expected Jump to Latest shortcut to include U") + } + + func testEmptyNotificationsPopoverBlocksTerminalTyping() throws { + let app = XCUIApplication() + app.launchArguments += ["-socketControlMode", "allowAll"] + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["CMUX_TAG"] = launchTag + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for empty popover blocking test. state=\(app.state.rawValue)" + ) XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0)) - XCTAssertTrue(waitForSocketPong(timeout: 8.0), "Expected control socket to respond") + guard let resolvedPath = resolveSocketPath(timeout: 8.0) else { + throw XCTSkip("Control socket unavailable in this test environment. requested=\(socketPath)") + } + socketPath = resolvedPath + let pingResponse = waitForSocketPong(timeout: 8.0) + guard pingResponse == "PONG" else { + throw XCTSkip("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "<nil>")") + } _ = socketCommand("clear_notifications") app.typeKey("i", modifierFlags: [.command]) XCTAssertTrue(app.staticTexts["No notifications yet"].waitForExistence(timeout: 6.0), "Expected empty notifications popover state") + let jumpButton = app.buttons["notificationsPopover.jumpToLatest"] + XCTAssertTrue(jumpButton.waitForExistence(timeout: 2.0), "Expected Jump to Latest button in empty notifications popover") + XCTAssertFalse(jumpButton.isEnabled, "Expected Jump to Latest button to be disabled with no notifications") + let clearAllButton = app.buttons["notificationsPopover.clearAll"] + XCTAssertTrue(clearAllButton.waitForExistence(timeout: 2.0), "Expected Clear All button in empty notifications popover") + XCTAssertFalse(clearAllButton.isEnabled, "Expected Clear All button to be disabled with no notifications") let marker = "cmux_notif_block_\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8))" let before = readCurrentTerminalText() ?? "" @@ -165,6 +221,159 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTAssertFalse(after.contains(marker), "Expected typing to be blocked while empty notifications popover is open") } + func testNotifyCLIDoesNotStealFocusAcrossWindows() throws { + let app = XCUIApplication() + app.launchArguments += ["-socketControlMode", "allowAll"] + app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_NOTIFY_SOURCE_TERMINAL_READY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] = "1" + app.launchEnvironment["CMUX_TAG"] = launchTag + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for notify focus regression test. state=\(app.state.rawValue)" + ) + XCTAssertTrue( + waitForDataMatch(timeout: 20.0) { data in + let tabId2 = data["tabId2"] ?? "" + let surfaceId2 = data["surfaceId2"] ?? "" + let socketReady = data["socketReady"] ?? "" + let sourceTerminalReady = data["sourceTerminalReady"] ?? "" + return !tabId2.isEmpty && + !surfaceId2.isEmpty && + !socketReady.isEmpty && + socketReady != "pending" && + !sourceTerminalReady.isEmpty && + sourceTerminalReady != "pending" + }, + "Expected multi-window notification setup data, socket readiness, and source terminal focus" + ) + + guard let setup = loadData() else { + XCTFail("Missing setup data") + return + } + guard let tabId2 = setup["tabId2"], !tabId2.isEmpty else { + XCTFail("Missing setup workspace id") + return + } + if let expectedSocketPath = setup["socketExpectedPath"], !expectedSocketPath.isEmpty { + socketPath = expectedSocketPath + } + if setup["socketReady"] != "1" { + XCTFail( + "Control socket unavailable in this test environment. expected=\(socketPath) " + + socketDiagnostics(from: setup) + ) + return + } + guard setup["socketPingResponse"] == "PONG" else { + XCTFail( + "Control socket ping sanity check failed. path=\(socketPath) " + + socketDiagnostics(from: setup) + ) + return + } + guard let surfaceId = setup["surfaceId2"], !surfaceId.isEmpty else { + XCTFail("Missing target surface id for workspace \(tabId2)") + return + } + guard setup["sourceTerminalReady"] == "1" else { + XCTFail( + "Expected source terminal to be focused before typing. " + + "failure=\(setup["sourceTerminalFocusFailure"] ?? "<unknown>")" + ) + return + } + + XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0)) + + let title = "focus-regression-\(UUID().uuidString.prefix(8))" + let commandResultStem = UUID().uuidString + let commandStatusPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).status") + .path + let commandStdoutPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).stdout") + .path + let commandStderrPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).stderr") + .path + let commandScriptPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).sh") + .path + defer { + try? FileManager.default.removeItem(atPath: commandStatusPath) + try? FileManager.default.removeItem(atPath: commandStdoutPath) + try? FileManager.default.removeItem(atPath: commandStderrPath) + try? FileManager.default.removeItem(atPath: commandScriptPath) + } + + guard let bundledCLIPath = resolveCmuxCLIPaths(strategy: .bundledOnly).first else { + XCTFail("Failed to locate bundled cmux CLI for notify regression test") + return + } + + let notifyScript = [ + "#!/bin/sh", + "sleep 1", + "rm -f \(shellSingleQuote(commandStatusPath)) \(shellSingleQuote(commandStdoutPath)) \(shellSingleQuote(commandStderrPath))", + "\(shellSingleQuote(bundledCLIPath)) --socket \(shellSingleQuote(socketPath)) notify --workspace \(shellSingleQuote(tabId2)) --surface \(shellSingleQuote(surfaceId)) --title \(shellSingleQuote(title)) --subtitle \(shellSingleQuote("ui-test")) --body \(shellSingleQuote("focus-regression")) >\(shellSingleQuote(commandStdoutPath)) 2>\(shellSingleQuote(commandStderrPath))", + "printf '%s' $? >\(shellSingleQuote(commandStatusPath))" + ].joined(separator: "\n") + do { + try notifyScript.write(toFile: commandScriptPath, atomically: true, encoding: .utf8) + } catch { + XCTFail( + "Failed to write delayed bundled `cmux notify` script. " + + "path=\(commandScriptPath) error=\(error)" + ) + return + } + + app.typeText("sh \(commandScriptPath)") + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + let finder = XCUIApplication(bundleIdentifier: "com.apple.finder") + finder.activate() + XCTAssertTrue( + waitForAppToLeaveForeground(app, timeout: 8.0), + "Expected cmux to move to background before delayed notify command runs. state=\(app.state.rawValue)" + ) + + XCTAssertTrue( + waitForCommandCompletionWhileBackgrounded( + statusPath: commandStatusPath, + app: app, + timeout: 15.0 + ), + "Expected delayed bundled `cmux notify` command to finish without foregrounding cmux. state=\(app.state.rawValue)" + ) + + let notifyExitStatus = readTrimmedFile(atPath: commandStatusPath) ?? "<missing>" + let notifyStdout = readTrimmedFile(atPath: commandStdoutPath) ?? "" + let notifyStderr = readTrimmedFile(atPath: commandStderrPath) ?? "" + + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + XCTAssertFalse( + app.state == .runningForeground, + "Expected cmux to remain in background after bundled `cmux notify`. state=\(app.state.rawValue) stderr=\(notifyStderr)" + ) + guard notifyExitStatus == "0" else { + XCTFail( + "Expected bundled `cmux notify` launched from the in-app shell to succeed. " + + "status=\(notifyExitStatus) stdout=\(notifyStdout) stderr=\(notifyStderr)" + ) + return + } + XCTAssertTrue(notifyStdout.contains("OK"), "Expected notify command to return OK. stdout=\(notifyStdout) stderr=\(notifyStderr)") + } + private func clickNotificationPopoverRowAndWaitForFocusChange( button: XCUIElement, app: XCUIApplication, @@ -198,6 +407,17 @@ final class MultiWindowNotificationsUITests: XCTestCase { return app.windows.count >= count } + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + private func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool { let predicate = NSPredicate(format: "exists == false") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) @@ -238,31 +458,604 @@ final class MultiWindowNotificationsUITests: XCTestCase { return false } - private func waitForSocketPong(timeout: TimeInterval) -> Bool { + private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - if socketCommand("ping") == "PONG" { + if let data = loadData(), predicate(data) { return true } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } + if let data = loadData(), predicate(data) { + return true + } + return false + } + + private func waitForSocketPong(timeout: TimeInterval) -> String? { + let deadline = Date().addingTimeInterval(timeout) + var lastResponse: String? + while Date() < deadline { + lastResponse = socketCommand("ping") + if lastResponse == "PONG" { + return "PONG" + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return socketCommand("ping") ?? lastResponse + } + + private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if socketCommand("is_terminal_focused \(surfaceId)") == "true" { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return socketCommand("is_terminal_focused \(surfaceId)") == "true" + } + + private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) { + let deadline = Date().addingTimeInterval(timeout) + var lastStdout: String? + var lastStderr: String? + while Date() < deadline { + let result = runCmuxCommand( + socketPath: socketPath, + arguments: ["ping"], + responseTimeoutSeconds: 2.0 + ) + let stdout = result.stdout.isEmpty ? nil : result.stdout + let stderr = result.stderr.isEmpty ? nil : result.stderr + if let stdout { + lastStdout = stdout + } + if let stderr { + lastStderr = stderr + } + if result.terminationStatus == 0, stdout == "PONG" { + return ("PONG", stderr) + } + if isSocketPermissionFailure(stderr), + waitForSocketPong(timeout: 0.5) == "PONG" { + return ("PONG", stderr) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + let result = runCmuxCommand( + socketPath: socketPath, + arguments: ["ping"], + responseTimeoutSeconds: 2.0 + ) + let stdout = result.stdout.isEmpty ? nil : result.stdout + let stderr = result.stderr.isEmpty ? nil : result.stderr + if isSocketPermissionFailure(stderr), + waitForSocketPong(timeout: 0.5) == "PONG" { + return ("PONG", stderr) + } + return (stdout ?? lastStdout, stderr ?? lastStderr) + } + + private func waitForCommandCompletionWhileBackgrounded( + statusPath: String, + app: XCUIApplication, + timeout: TimeInterval + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + var sawCompletion = false + while Date() < deadline { + if app.state == .runningForeground { + return false + } + if FileManager.default.fileExists(atPath: statusPath) { + sawCompletion = true + break + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + guard sawCompletion || FileManager.default.fileExists(atPath: statusPath) else { + return false + } + + let postCompletionDeadline = Date().addingTimeInterval(0.75) + while Date() < postCompletionDeadline { + if app.state == .runningForeground { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return app.state != .runningForeground + } + + private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if app.state != .runningForeground { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return app.state != .runningForeground + } + + private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? { + guard let response = socketCommand("list_surfaces \(workspaceId)"), + !response.isEmpty, + !response.hasPrefix("ERROR"), + response != "No surfaces" else { + return nil + } + + for line in response.split(separator: "\n", omittingEmptySubsequences: true) { + let parts = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { continue } + let candidate = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) + if UUID(uuidString: candidate) != nil { + return candidate + } + } + return nil + } + + private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) { + return surfaceId + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return firstSurfaceId(forWorkspaceId: workspaceId) + } + + private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) { + return surfaceId + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + } + + private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? { + guard let paneId = firstPaneIdViaCLI(forWorkspaceId: workspaceId) else { + return firstSurfaceId(forWorkspaceId: workspaceId) + } + let result = runCmuxCommand( + socketPath: socketPath, + arguments: [ + "list-pane-surfaces", + "--workspace", + workspaceId, + "--pane", + paneId, + "--id-format", + "uuids" + ], + responseTimeoutSeconds: 3.0 + ) + guard result.terminationStatus == 0 else { + if isSocketPermissionFailure(result.stderr) { + return firstSurfaceId(forWorkspaceId: workspaceId) + } + return nil + } + return firstHandle(in: result.stdout) + } + + private func firstPaneIdViaCLI(forWorkspaceId workspaceId: String) -> String? { + let result = runCmuxCommand( + socketPath: socketPath, + arguments: [ + "list-panes", + "--workspace", + workspaceId, + "--id-format", + "uuids" + ], + responseTimeoutSeconds: 3.0 + ) + guard result.terminationStatus == 0 else { + if isSocketPermissionFailure(result.stderr) { + return nil + } + return nil + } + return firstHandle(in: result.stdout) + } + + private func firstHandle(in output: String) -> String? { + for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) { + var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty, !line.hasPrefix("No ") else { continue } + if line.hasPrefix("* ") || line.hasPrefix(" ") { + line = String(line.dropFirst(2)) + } + guard let token = line.split(whereSeparator: \.isWhitespace).first else { continue } + return String(token) + } + return nil + } + + private func runCmuxNotify( + socketPath: String, + workspaceId: String, + surfaceId: String, + title: String + ) -> (terminationStatus: Int32, stdout: String, stderr: String) { + runCmuxCommand( + socketPath: socketPath, + arguments: [ + "notify", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--title", + title, + "--subtitle", + "ui-test", + "--body", + "focus-regression" + ], + responseTimeoutSeconds: 4.0, + cliStrategy: .bundledOnly + ) + } + + private func runCmuxCommand( + socketPath: String, + arguments: [String], + responseTimeoutSeconds: Double = 3.0, + cliStrategy: CmuxCLIStrategy = .any + ) -> (terminationStatus: Int32, stdout: String, stderr: String) { + var args = ["--socket", socketPath] + args.append(contentsOf: arguments) + var environment = ProcessInfo.processInfo.environment + environment["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"] = String(responseTimeoutSeconds) + + let cliPaths = resolveCmuxCLIPaths(strategy: cliStrategy) + if cliPaths.isEmpty, cliStrategy == .bundledOnly { + return ( + terminationStatus: -1, + stdout: "", + stderr: "Failed to locate bundled cmux CLI" + ) + } + + var lastPermissionFailure: (terminationStatus: Int32, stdout: String, stderr: String)? + for cliPath in cliPaths { + let result = executeCmuxCommand( + executablePath: cliPath, + arguments: args, + environment: environment + ) + if result.terminationStatus == 0 { + return result + } + if result.stderr.localizedCaseInsensitiveContains("operation not permitted") { + lastPermissionFailure = result + continue + } + return result + } + + if cliStrategy == .bundledOnly { + return lastPermissionFailure ?? ( + terminationStatus: -1, + stdout: "", + stderr: "Bundled cmux CLI command failed without an executable path" + ) + } + + let fallbackArgs = ["cmux"] + args + let fallbackResult = executeCmuxCommand( + executablePath: "/usr/bin/env", + arguments: fallbackArgs, + environment: environment + ) + if fallbackResult.terminationStatus == 0 || lastPermissionFailure == nil { + return fallbackResult + } + return lastPermissionFailure ?? fallbackResult + } + + private enum CmuxCLIStrategy: Equatable { + case any + case bundledOnly + } + + private func socketDiagnostics(from data: [String: String]) -> String { + let pingResponse = data["socketPingResponse"].flatMap { $0.isEmpty ? nil : $0 } ?? "<nil>" + return "mode=\(data["socketMode"] ?? "") running=\(data["socketIsRunning"] ?? "") " + + "acceptLoopAlive=\(data["socketAcceptLoopAlive"] ?? "") pathMatches=\(data["socketPathMatches"] ?? "") " + + "pathExists=\(data["socketPathExists"] ?? "") ping=\(pingResponse) " + + "signals=\(data["socketFailureSignals"] ?? "")" + } + + private func resolveCmuxCLIPaths(strategy: CmuxCLIStrategy) -> [String] { + let fileManager = FileManager.default + let env = ProcessInfo.processInfo.environment + var candidates: [String] = [] + var productDirectories: [String] = [] + + if strategy == .any { + for key in ["CMUX_UI_TEST_CLI_PATH", "CMUXTERM_CLI"] { + if let value = env[key], !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(value) + } + } + } + + if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty { + productDirectories.append(builtProductsDir) + } + + if let hostPath = env["TEST_HOST"], !hostPath.isEmpty { + let hostURL = URL(fileURLWithPath: hostPath) + let productsDir = hostURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + productDirectories.append(productsDir) + } + + productDirectories.append(contentsOf: inferredBuildProductsDirectories()) + for productsDir in uniquePaths(productDirectories) { + appendCLIPathCandidates(fromProductsDirectory: productsDir, strategy: strategy, to: &candidates) + } + + candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux DEV.app/Contents/Resources/bin/cmux") + candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux.app/Contents/Resources/bin/cmux") + if strategy == .any { + candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux") + } + + var resolvedPaths: [String] = [] + for path in uniquePaths(candidates) { + guard fileManager.isExecutableFile(atPath: path) else { continue } + resolvedPaths.append(URL(fileURLWithPath: path).resolvingSymlinksInPath().path) + } + return uniquePaths(resolvedPaths) + } + + private func inferredBuildProductsDirectories() -> [String] { + let bundleURLs = [ + Bundle.main.bundleURL, + Bundle(for: Self.self).bundleURL, + ] + + return bundleURLs.compactMap { bundleURL in + let standardizedPath = bundleURL.standardizedFileURL.path + let components = standardizedPath.split(separator: "/") + guard let productsIndex = components.firstIndex(of: "Products"), + productsIndex + 1 < components.count else { + return nil + } + let prefixComponents = components.prefix(productsIndex + 2) + return "/" + prefixComponents.joined(separator: "/") + } + } + + private func appendCLIPathCandidates( + fromProductsDirectory productsDir: String, + strategy: CmuxCLIStrategy, + to candidates: inout [String] + ) { + candidates.append("\(productsDir)/cmux DEV.app/Contents/Resources/bin/cmux") + candidates.append("\(productsDir)/cmux.app/Contents/Resources/bin/cmux") + if strategy == .any { + candidates.append("\(productsDir)/cmux") + } + + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: productsDir) else { + return + } + + for entry in entries.sorted() where entry.hasSuffix(".app") { + let cliPath = URL(fileURLWithPath: productsDir) + .appendingPathComponent(entry) + .appendingPathComponent("Contents/Resources/bin/cmux") + .path + candidates.append(cliPath) + } + if strategy == .any { + for entry in entries.sorted() where entry == "cmux" { + let cliPath = URL(fileURLWithPath: productsDir) + .appendingPathComponent(entry) + .path + candidates.append(cliPath) + } + } + } + + private func executeCmuxCommand( + executablePath: String, + arguments: [String], + environment: [String: String] + ) -> (terminationStatus: Int32, stdout: String, stderr: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + process.waitUntilExit() + } catch { + return ( + terminationStatus: -1, + stdout: "", + stderr: "Failed to run cmux command: \(error.localizedDescription) (cliPath=\(executablePath))" + ) + } + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stdout = String(data: stdoutData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let rawStderr = String(data: stderrData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stderr = rawStderr.isEmpty ? "" : "\(rawStderr) (cliPath=\(executablePath))" + return (process.terminationStatus, stdout, stderr) + } + + private func isSocketPermissionFailure(_ stderr: String?) -> Bool { + guard let stderr, !stderr.isEmpty else { return false } + return stderr.localizedCaseInsensitiveContains("failed to connect to socket") && + stderr.localizedCaseInsensitiveContains("operation not permitted") + } + + private func uniquePaths(_ paths: [String]) -> [String] { + var unique: [String] = [] + var seen = Set<String>() + for path in paths { + if seen.insert(path).inserted { + unique.append(path) + } + } + return unique + } + + private func resolveSocketPath(timeout: TimeInterval, requiredWorkspaceId: String? = nil) -> String? { + let primaryCandidates = expectedSocketCandidates(includeGlobalFallback: false) + let fallbackCandidates: [String] + if let requiredWorkspaceId, !requiredWorkspaceId.isEmpty { + fallbackCandidates = expectedSocketCandidates(includeGlobalFallback: true) + .filter { !primaryCandidates.contains($0) } + } else { + fallbackCandidates = [] + } + + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + for candidate in primaryCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + // Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds, + // prefer it even before workspace contents are fully initialized. + if socketRespondsToPing(at: candidate) { + return candidate + } + } + for candidate in fallbackCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if socketRespondsToPing(at: candidate), + socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + return candidate + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + for candidate in primaryCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if socketRespondsToPing(at: candidate) { + return candidate + } + } + for candidate in fallbackCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if socketRespondsToPing(at: candidate), + socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + return candidate + } + } + return nil + } + + private func expectedSocketCandidates(includeGlobalFallback: Bool) -> [String] { + var candidates = [socketPath] + let taggedDebugSocket = "/tmp/cmux-debug-\(launchTag).sock" + if !taggedDebugSocket.isEmpty { + candidates.append(taggedDebugSocket) + } + if includeGlobalFallback { + candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12)) + candidates.append("/tmp/cmux-debug.sock") + candidates.append("/tmp/cmux.sock") + } + + var unique: [String] = [] + var seen = Set<String>() + for candidate in candidates { + if seen.insert(candidate).inserted { + unique.append(candidate) + } + } + return unique + } + + private func socketMatchesRequiredWorkspace(_ candidatePath: String, workspaceId: String?) -> Bool { + guard let workspaceId, !workspaceId.isEmpty else { return true } + let originalPath = socketPath + socketPath = candidatePath + defer { socketPath = originalPath } + + guard let response = socketCommand("list_surfaces \(workspaceId)"), + !response.isEmpty, + !response.hasPrefix("ERROR"), + response != "No surfaces" else { + return false + } + return true + } + + private func discoverTmpSocketCandidates(limit: Int) -> [String] { + let tmpPath = "/tmp" + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else { + return [] + } + + let matches = entries.filter { $0.hasPrefix("cmux") && $0.hasSuffix(".sock") } + let sorted = matches.compactMap { entry -> (path: String, mtime: Date)? in + let fullPath = (tmpPath as NSString).appendingPathComponent(entry) + guard let attrs = try? FileManager.default.attributesOfItem(atPath: fullPath) else { + return nil + } + let mtime = (attrs[.modificationDate] as? Date) ?? .distantPast + return (fullPath, mtime) + } + .sorted { $0.mtime > $1.mtime } + + return Array(sorted.prefix(limit)).map(\.path) + } + + private func socketRespondsToPing(at path: String) -> Bool { + let originalPath = socketPath + socketPath = path + defer { socketPath = originalPath } return socketCommand("ping") == "PONG" } - private func socketCommand(_ cmd: String) -> String? { + private func socketCommand(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? { + if let response = ControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(cmd) { + return response + } + return socketCommandViaNetcat(cmd, responseTimeout: responseTimeout) + } + + private func socketCommandViaNetcat(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? { let nc = "/usr/bin/nc" guard FileManager.default.isExecutableFile(atPath: nc) else { return nil } let proc = Process() - proc.executableURL = URL(fileURLWithPath: nc) - proc.arguments = ["-U", socketPath, "-w", "2"] + proc.executableURL = URL(fileURLWithPath: "/bin/sh") + let timeoutSeconds = max(1, Int(ceil(responseTimeout))) + let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w \(timeoutSeconds) 2>/dev/null" + proc.arguments = ["-lc", script] - let inPipe = Pipe() let outPipe = Pipe() - let errPipe = Pipe() - proc.standardInput = inPipe proc.standardOutput = outPipe - proc.standardError = errPipe do { try proc.run() @@ -270,11 +1063,6 @@ final class MultiWindowNotificationsUITests: XCTestCase { return nil } - if let data = (cmd + "\n").data(using: .utf8) { - inPipe.fileHandleForWriting.write(data) - } - inPipe.fileHandleForWriting.closeFile() - proc.waitUntilExit() let outData = outPipe.fileHandleForReading.readDataToEndOfFile() @@ -286,6 +1074,113 @@ final class MultiWindowNotificationsUITests: XCTestCase { return trimmed.isEmpty ? nil : trimmed } + private func shellSingleQuote(_ value: String) -> String { + if value.isEmpty { return "''" } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private func readTrimmedFile(atPath path: String) -> String? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private final class ControlSocketClient { + private let path: String + private let responseTimeout: TimeInterval + + init(path: String, responseTimeout: TimeInterval = 2.0) { + self.path = path + self.responseTimeout = responseTimeout + } + + func sendLine(_ line: String) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + +#if os(macOS) + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + ptr, + socklen_t(MemoryLayout<Int32>.size) + ) + } +#endif + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout<sockaddr_un>.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + let bytes = Array(path.utf8CString) + guard bytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { p in + let raw = UnsafeMutableRawPointer(p).assumingMemoryBound(to: CChar.self) + memset(raw, 0, maxLen) + for i in 0..<bytes.count { + raw[i] = bytes[i] + } + } + + let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + bytes.count) +#if os(macOS) + addr.sun_len = UInt8(min(Int(addrLen), 255)) +#endif + + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + connect(fd, sa, addrLen) + } + } + guard connected == 0 else { return nil } + + let payload = line + "\n" + let wrote: Bool = payload.withCString { cstr in + var remaining = strlen(cstr) + var p = UnsafeRawPointer(cstr) + while remaining > 0 { + let n = write(fd, p, remaining) + if n <= 0 { return false } + remaining -= n + p = p.advanced(by: n) + } + return true + } + guard wrote else { return nil } + + let deadline = Date().addingTimeInterval(responseTimeout) + var buf = [UInt8](repeating: 0, count: 4096) + var accum = "" + while Date() < deadline { + var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let ready = poll(&pollDescriptor, 1, 100) + if ready < 0 { + return nil + } + if ready == 0 { + continue + } + let n = read(fd, &buf, buf.count) + if n <= 0 { break } + if let chunk = String(bytes: buf[0..<n], encoding: .utf8) { + accum.append(chunk) + if let idx = accum.firstIndex(of: "\n") { + return String(accum[..<idx]) + } + } + } + return accum.isEmpty ? nil : accum.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + private func readCurrentTerminalText() -> String? { guard let response = socketCommand("read_terminal_text"), response.hasPrefix("OK ") else { return nil diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift new file mode 100644 index 00000000..9ba6cb4a --- /dev/null +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -0,0 +1,296 @@ +import XCTest + +private func sidebarHelpPollUntil( + 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 SidebarHelpMenuUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testHelpMenuOpensKeyboardShortcutsSection() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + launchAndActivate(app) + + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) + + let helpButton = requireElement( + candidates: helpButtonCandidates(in: app), + timeout: 6.0, + description: "sidebar help button" + ) + helpButton.click() + + let keyboardShortcutsItem = requireElement( + candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionKeyboardShortcuts", title: "Keyboard Shortcuts"), + timeout: 3.0, + description: "Keyboard Shortcuts help menu item" + ) + keyboardShortcutsItem.click() + + XCTAssertTrue(app.staticTexts["ShortcutRecordingHint"].waitForExistence(timeout: 6.0)) + } + + func testHelpMenuCheckForUpdatesTriggersSidebarUpdatePill() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmux.test/appcast.xml" + app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = "available" + app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9" + app.launchEnvironment["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1" + launchAndActivate(app) + + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) + + let helpButton = requireElement( + candidates: helpButtonCandidates(in: app), + timeout: 6.0, + description: "sidebar help button" + ) + helpButton.click() + + let checkForUpdatesItem = requireElement( + candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionCheckForUpdates", title: "Check for Updates"), + timeout: 3.0, + description: "Check for Updates help menu item" + ) + checkForUpdatesItem.click() + + let updatePill = app.buttons["UpdatePill"] + XCTAssertTrue(updatePill.waitForExistence(timeout: 6.0)) + XCTAssertEqual(updatePill.label, "Update Available: 9.9.9") + } + + func testHelpMenuSendFeedbackOpensComposerSheet() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + launchAndActivate(app) + + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) + + let helpButton = requireElement( + candidates: helpButtonCandidates(in: app), + timeout: 6.0, + description: "sidebar help button" + ) + helpButton.click() + + let sendFeedbackItem = requireElement( + candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionSendFeedback", title: "Send Feedback"), + timeout: 3.0, + description: "Send Feedback help menu item" + ) + sendFeedbackItem.click() + + XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0)) + XCTAssertTrue( + firstExistingElement( + candidates: [ + app.textFields["SidebarFeedbackEmailField"], + app.textFields["Your Email"], + ], + timeout: 2.0 + ) != nil + ) + XCTAssertTrue( + firstExistingElement( + candidates: [ + app.buttons["SidebarFeedbackAttachButton"], + app.buttons["Attach Images"], + ], + timeout: 2.0 + ) != nil + ) + XCTAssertTrue( + firstExistingElement( + candidates: [ + app.buttons["SidebarFeedbackSendButton"], + app.buttons["Send"], + ], + timeout: 2.0 + ) != nil + ) + XCTAssertTrue( + app.staticTexts[ + "A human will read this! You can also reach us at founders@manaflow.com." + ].waitForExistence(timeout: 2.0) + ) + + let messageEditor = requireElement( + candidates: [ + app.textViews["SidebarFeedbackMessageEditor"], + app.scrollViews["SidebarFeedbackMessageEditor"], + app.otherElements["SidebarFeedbackMessageEditor"], + app.textViews["Message"], + ], + timeout: 2.0, + description: "feedback message editor" + ) + messageEditor.click() + app.typeText("hello") + XCTAssertTrue(app.staticTexts["5/4000"].waitForExistence(timeout: 2.0)) + } + + private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { + sidebarHelpPollUntil(timeout: timeout) { + app.windows.count >= count + } + } + + private func helpButtonCandidates(in app: XCUIApplication) -> [XCUIElement] { + let sidebar = app.otherElements["Sidebar"] + return [ + app.buttons["SidebarHelpMenuButton"], + app.buttons["Help"], + sidebar.buttons["SidebarHelpMenuButton"], + sidebar.buttons["Help"], + ] + } + + private func helpMenuItemCandidates( + in app: XCUIApplication, + identifier: String, + title: String + ) -> [XCUIElement] { + [ + app.buttons[identifier], + app.buttons[title], + ] + } + + private func firstExistingElement( + candidates: [XCUIElement], + timeout: TimeInterval + ) -> XCUIElement? { + var match: XCUIElement? + let found = sidebarHelpPollUntil(timeout: timeout) { + for candidate in candidates where candidate.exists { + match = candidate + return true + } + return false + } + return found ? match : nil + } + + private func requireElement( + candidates: [XCUIElement], + timeout: TimeInterval, + description: String + ) -> XCUIElement { + guard let element = firstExistingElement(candidates: candidates, timeout: timeout) else { + XCTFail("Expected \(description) to exist") + return candidates[0] + } + return element + } + + private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { + app.launch() + let activated = sidebarHelpPollUntil(timeout: activateTimeout) { + guard app.state != .runningForeground else { + return true + } + app.activate() + return app.state == .runningForeground + } + if !activated { + app.activate() + } + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 2.0) { app.state == .runningForeground }, + "App did not reach runningForeground before UI interactions" + ) + } +} + +final class FeedbackComposerShortcutUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testCmdOptionFOpensFeedbackComposer() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launch() + app.activate() + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 6.0) { + app.windows.count >= 1 + } + ) + + app.typeKey("f", modifierFlags: [.command, .option]) + + XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0)) + XCTAssertTrue( + app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0) + || app.textFields["Your Email"].waitForExistence(timeout: 2.0) + ) + } + + func testCmdOptionFWorksWithHiddenSidebar() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launch() + app.activate() + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 6.0) { + app.windows.count >= 1 + } + ) + + app.typeKey("b", modifierFlags: [.command]) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + !app.buttons["SidebarHelpMenuButton"].exists && !app.buttons["Help"].exists + } + ) + + app.typeKey("f", modifierFlags: [.command, .option]) + + XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0)) + } + + func testCmdOptionFWorksFromSettingsWindow() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1" + app.launch() + app.activate() + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 6.0) { + app.windows.count >= 2 + } + ) + + app.typeKey("f", modifierFlags: [.command, .option]) + + XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0)) + XCTAssertTrue( + app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0) + || app.textFields["Your Email"].waitForExistence(timeout: 2.0) + ) + } +} diff --git a/cmuxUITests/SidebarResizeUITests.swift b/cmuxUITests/SidebarResizeUITests.swift index 57c47214..cdca1146 100644 --- a/cmuxUITests/SidebarResizeUITests.swift +++ b/cmuxUITests/SidebarResizeUITests.swift @@ -13,6 +13,7 @@ final class SidebarResizeUITests: XCTestCase { let elements = app.descendants(matching: .any) let resizer = elements["SidebarResizer"] XCTAssertTrue(resizer.waitForExistence(timeout: 5.0)) + XCTAssertTrue(waitForElementHittable(resizer, timeout: 5.0), "Expected sidebar resizer to become hittable") let initialX = resizer.frame.minX @@ -35,4 +36,46 @@ final class SidebarResizeUITests: XCTestCase { XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left") XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset") } + + func testSidebarResizerHasMaximumWidthCap() { + let app = XCUIApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0)) + + let elements = app.descendants(matching: .any) + let resizer = elements["SidebarResizer"] + XCTAssertTrue(resizer.waitForExistence(timeout: 5.0)) + XCTAssertTrue(waitForElementHittable(resizer, timeout: 5.0), "Expected sidebar resizer to become hittable") + + let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let farRight = start.withOffset(CGVector(dx: max(1200, window.frame.width * 2.0), dy: 0)) + start.press(forDuration: 0.1, thenDragTo: farRight) + + let windowFrame = window.frame + let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX) + let minimumExpectedRemaining = windowFrame.width * 0.45 + + XCTAssertGreaterThanOrEqual( + remainingWidth, + minimumExpectedRemaining, + "Expected sidebar max-width clamp to leave substantial terminal width. " + + "remaining=\(remainingWidth), window=\(windowFrame.width)" + ) + } + + private func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if element.exists, element.isHittable { + let frame = element.frame + if frame.width > 1, frame.height > 1 { + return true + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return false + } } diff --git a/cmuxUITests/UpdatePillUITests.swift b/cmuxUITests/UpdatePillUITests.swift index 88c00b53..3b8040a7 100644 --- a/cmuxUITests/UpdatePillUITests.swift +++ b/cmuxUITests/UpdatePillUITests.swift @@ -1,6 +1,24 @@ import XCTest import Foundation +// UI runners can adjust wall clock time mid-test; use monotonic uptime for polling deadlines. +private func pollUntil( + timeout: TimeInterval, + pollInterval: TimeInterval = 0.05, + condition: () -> Bool +) -> Bool { + let start = ProcessInfo.processInfo.systemUptime + while true { + if condition() { + return true + } + if (ProcessInfo.processInfo.systemUptime - start) >= timeout { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } +} + final class UpdatePillUITests: XCTestCase { override func setUp() { super.setUp() @@ -131,25 +149,28 @@ final class UpdatePillUITests: XCTestCase { } private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + pollUntil(timeout: timeout) { + app.windows.count >= count } - return app.windows.count >= count } private func assertVisibleSize(_ element: XCUIElement, timeout: TimeInterval = 2.0) { - let deadline = Date().addingTimeInterval(timeout) + let pollInterval: TimeInterval = 0.05 var size = element.frame.size - while Date() < deadline { + var exists = element.exists + var hittable = element.isHittable + + let visible = pollUntil(timeout: timeout, pollInterval: pollInterval) { size = element.frame.size - if size.width > 20 && size.height > 10 { - return - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + exists = element.exists + hittable = element.isHittable + return size.width > 20 && size.height > 10 + } + if !visible { + XCTFail( + "Expected UpdatePill to have visible size, got \(size), exists=\(exists), hittable=\(hittable)" + ) } - XCTFail("Expected UpdatePill to have visible size, got \(size)") } private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) { @@ -197,12 +218,14 @@ final class UpdatePillUITests: XCTestCase { private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { app.launch() - let deadline = Date().addingTimeInterval(activateTimeout) - while Date() < deadline, app.state != .runningForeground { + let activated = pollUntil(timeout: activateTimeout) { + guard app.state != .runningForeground else { + return true + } app.activate() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return app.state == .runningForeground } - if app.state != .runningForeground { + if !activated { app.activate() } } @@ -274,7 +297,7 @@ final class TitlebarShortcutHintsUITests: XCTestCase { XCTAssertEqual(sidebarHintFrame.minY, notificationsHintFrame.minY, accuracy: 1.0) XCTAssertEqual(notificationsHintFrame.minY, newTabHintFrame.minY, accuracy: 1.0) // Keep the sidebar hint lane to the right of the sidebar icon so it cannot clip into the traffic-light backdrop. - XCTAssertGreaterThanOrEqual(sidebarHintFrame.minX, hintedToggleFrame.minX - 1.0) + XCTAssertGreaterThanOrEqual(sidebarHintFrame.minX, hintedToggleFrame.minX - 4.0) let sortedHintFrames = [sidebarHintFrame, notificationsHintFrame, newTabHintFrame] .sorted { $0.minX < $1.minX } @@ -293,40 +316,32 @@ final class TitlebarShortcutHintsUITests: XCTestCase { app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"] app.launch() - let deadline = Date().addingTimeInterval(2.0) - while Date() < deadline, app.state != .runningForeground { + _ = pollUntil(timeout: 2.0) { + guard app.state != .runningForeground else { + return true + } app.activate() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return app.state == .runningForeground } return app } private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + pollUntil(timeout: timeout) { + app.windows.count >= count } - return app.windows.count >= count } private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + pollUntil(timeout: timeout) { if element.exists { let frame = element.frame if frame.width > 1, frame.height > 1 { return true } } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } - - if element.exists { - let frame = element.frame - return frame.width > 1 && frame.height > 1 - } - return false } } diff --git a/design/cmux-icon-chevron.png b/design/cmux-icon-chevron.png new file mode 100644 index 00000000..9e5f23f1 Binary files /dev/null and b/design/cmux-icon-chevron.png differ diff --git a/design/cmux.icon/Assets/cmux-icon-chevron 2.png b/design/cmux.icon/Assets/cmux-icon-chevron 2.png new file mode 100644 index 00000000..9e5f23f1 Binary files /dev/null and b/design/cmux.icon/Assets/cmux-icon-chevron 2.png differ diff --git a/design/cmux.icon/icon.json b/design/cmux.icon/icon.json new file mode 100644 index 00000000..e4ddba51 --- /dev/null +++ b/design/cmux.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill" : "automatic", + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name" : "cmux-icon-chevron 2.png", + "name" : "cmux-icon-chevron 2", + "position" : { + "scale" : 1, + "translation-in-points" : [ + 37.357790031201375, + -0.5 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/docs/assets/split-cwd-inheritance-demo.gif b/docs/assets/split-cwd-inheritance-demo.gif new file mode 100644 index 00000000..5a1c1c9c Binary files /dev/null and b/docs/assets/split-cwd-inheritance-demo.gif differ diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index e5ed8988..30ba29bf 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,9 +12,11 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes +Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 2026. + ### 1) OSC 99 (kitty) notification parser -- Commit: `4713b7e23` (Add OSC 99 notification parser) +- Commit: `a2252e7a9` (Add OSC 99 notification parser) - Files: - `src/terminal/osc.zig` - `src/terminal/osc/parsers.zig` @@ -24,13 +26,70 @@ When we change the fork, update this document and the parent submodule SHA. ### 2) macOS display link restart on display changes -- Commit: `7c2562cbe` (macos: restart display link after display ID change) +- Commit: `c07e6c5a5` (macos: restart display link after display ID change) - Files: - `src/renderer/generic.zig` - Summary: - Restarts the CVDisplayLink when `setMacOSDisplayID` updates the current CGDisplay. - Prevents a rare state where vsync is "running" but no callbacks arrive, which can look like a frozen surface until focus/occlusion changes. +### 3) Keyboard copy mode selection C API + +- Commit: `a50579bd5` (Add C API for keyboard copy mode selection) +- Files: + - `src/Surface.zig` + - `src/apprt/embedded.zig` +- Summary: + - Restores `ghostty_surface_select_cursor_cell` and `ghostty_surface_clear_selection`. + - Keeps cmux keyboard copy mode working against the refreshed Ghostty base. + +### 4) macOS resize stale-frame mitigation + +Sections 3 and 4 are grouped by feature, not by commit order. The section 4 resize commits were +applied earlier than the section 3 copy-mode commit, but they are kept together here because they +touch the same stale-frame mitigation path and tend to conflict in the same files during rebases. + +- Commits: + - `769bbf7a9` (macos: reduce transient blank/scaled frames during resize) + - `9efcdfdf8` (macos: keep top-left gravity for stale-frame replay) +- Files: + - `pkg/macos/animation.zig` + - `src/Surface.zig` + - `src/apprt/embedded.zig` + - `src/renderer/Metal.zig` + - `src/renderer/generic.zig` + - `src/renderer/metal/IOSurfaceLayer.zig` +- Summary: + - Replays the last rendered frame during resize and keeps its geometry anchored correctly. + - Reduces transient blank or scaled frames while a macOS window is being resized. + +### 5) zsh prompt redraw markers use OSC 133 P + +- Commit: `8ade43ce5` (zsh: use OSC 133 P for prompt redraws) +- Files: + - `src/shell-integration/zsh/ghostty-integration` +- Summary: + - Emits one `OSC 133;A` fresh-prompt mark for real prompt transitions. + - Uses `OSC 133;P` markers for prompt redraws so async zsh themes do not look like extra prompt lines. + +### 6) zsh Pure-style multiline prompt redraws + +- Commit: `0cf559581` (zsh: fix Pure-style multiline prompt redraws) +- Files: + - `src/shell-integration/zsh/ghostty-integration` +- Summary: + - Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line. + - Places the continuation marker after Pure's hidden carriage return so async redraws do not leave stale preprompt lines behind. + +The fork branch HEAD is now the section 6 zsh redraw commit. + +## Upstreamed fork changes + +### cursor-click-to-move respects OSC 133 click-to-move + +- Was local in the fork as `10a585754`. +- Landed upstream as `bb646926f`, so it is no longer carried as a fork-only patch. + ## Merge conflict notes These files change frequently upstream; be careful when rebasing the fork: @@ -42,4 +101,9 @@ These files change frequently upstream; be careful when rebasing the fork: - `src/terminal/osc.zig` - OSC dispatch logic moves often. Re-check the integration points for the OSC 99 parser. +- `src/shell-integration/zsh/ghostty-integration` + - Prompt marker handling is easy to regress when upstream adjusts zsh redraw behavior. Keep the + `OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes, and preserve the special + handling for Pure-style `\n%{\r%}` prompt newlines. + If you resolve a conflict, update this doc with what changed. diff --git a/ghostty b/ghostty index 80d3fa07..0cf55958 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 80d3fa07ff8ae86fe6089083371f71ac7634648f +Subproject commit 0cf5595817794466e3a60abe6bf97f8494dedcfe diff --git a/ghostty.h b/ghostty.h index 3d397308..585564d7 100644 --- a/ghostty.h +++ b/ghostty.h @@ -463,6 +463,12 @@ typedef struct { // Config types +// config.Path +typedef struct { + const char* path; + bool optional; +} ghostty_config_path_s; + // config.Color typedef struct { uint8_t r; @@ -1108,6 +1114,8 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t, void*, bool); bool ghostty_surface_has_selection(ghostty_surface_t); +bool ghostty_surface_select_cursor_cell(ghostty_surface_t); +bool ghostty_surface_clear_selection(ghostty_surface_t); bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); bool ghostty_surface_read_text(ghostty_surface_t, ghostty_selection_s, diff --git a/scripts/build-sign-upload.sh b/scripts/build-sign-upload.sh index 06f4e8d8..08d1f84c 100755 --- a/scripts/build-sign-upload.sh +++ b/scripts/build-sign-upload.sh @@ -61,7 +61,7 @@ echo "Pre-flight checks passed" # --- Build GhosttyKit (if needed) --- if [ ! -d "GhosttyKit.xcframework" ]; then echo "Building GhosttyKit..." - cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast && cd .. + cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast && cd .. rm -rf GhosttyKit.xcframework cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework else @@ -177,6 +177,7 @@ cask "cmux" do depends_on macos: ">= :ventura" app "cmux.app" + binary "#{appdir}/cmux.app/Contents/Resources/bin/cmux" zap trash: [ "~/Library/Application Support/cmux", diff --git a/scripts/create-virtual-display.m b/scripts/create-virtual-display.m new file mode 100644 index 00000000..d3df1bae --- /dev/null +++ b/scripts/create-virtual-display.m @@ -0,0 +1,93 @@ +// Creates a virtual display on headless macOS (CI runners without a physical monitor). +// Uses the private CGVirtualDisplay API from CoreGraphics. +// The display stays alive as long as this process runs. +// +// Build: clang -framework Foundation -framework CoreGraphics -o create-virtual-display create-virtual-display.m +// Usage: ./create-virtual-display & + +#import <Foundation/Foundation.h> +#import <objc/runtime.h> + +// Private CoreGraphics classes (declared here since they're not in public headers) +@interface CGVirtualDisplayMode : NSObject +- (instancetype)initWithWidth:(unsigned int)width height:(unsigned int)height refreshRate:(double)refreshRate; +@end + +@interface CGVirtualDisplayDescriptor : NSObject +@property (nonatomic, copy) NSString *name; +@property (nonatomic) unsigned int maxPixelsWide; +@property (nonatomic) unsigned int maxPixelsHigh; +@property (nonatomic) CGSize sizeInMillimeters; +@property (nonatomic) unsigned int vendorID; +@property (nonatomic) unsigned int productID; +@property (nonatomic) unsigned int serialNum; +@property (nonatomic, strong) dispatch_queue_t queue; +@end + +@interface CGVirtualDisplaySettings : NSObject +@property (nonatomic) unsigned int hiDPI; +@property (nonatomic, strong) NSArray *modes; +@end + +@interface CGVirtualDisplay : NSObject +- (instancetype)initWithDescriptor:(CGVirtualDisplayDescriptor *)descriptor; +- (BOOL)applySettings:(CGVirtualDisplaySettings *)settings; +@property (nonatomic, readonly) unsigned int displayID; +@end + +int main(int argc, const char *argv[]) { + @autoreleasepool { + unsigned int width = 1920; + unsigned int height = 1080; + + // Verify the private classes exist + if (!NSClassFromString(@"CGVirtualDisplay")) { + fprintf(stderr, "ERROR: CGVirtualDisplay API not available on this system\n"); + return 1; + } + + // Create display mode + CGVirtualDisplayMode *mode = [[CGVirtualDisplayMode alloc] initWithWidth:width height:height refreshRate:60.0]; + if (!mode) { + fprintf(stderr, "ERROR: Failed to create CGVirtualDisplayMode\n"); + return 1; + } + + // Configure descriptor + CGVirtualDisplayDescriptor *descriptor = [[CGVirtualDisplayDescriptor alloc] init]; + descriptor.name = @"CI Virtual Display"; + descriptor.maxPixelsWide = width; + descriptor.maxPixelsHigh = height; + descriptor.sizeInMillimeters = CGSizeMake(530, 300); + descriptor.vendorID = 0x1234; + descriptor.productID = 0x5678; + descriptor.serialNum = 0x0001; + descriptor.queue = dispatch_get_main_queue(); + + // Create virtual display + CGVirtualDisplay *display = [[CGVirtualDisplay alloc] initWithDescriptor:descriptor]; + if (!display) { + fprintf(stderr, "ERROR: Failed to create CGVirtualDisplay\n"); + return 1; + } + + // Apply settings with display mode + CGVirtualDisplaySettings *settings = [[CGVirtualDisplaySettings alloc] init]; + settings.hiDPI = 0; + settings.modes = @[mode]; + + BOOL ok = [display applySettings:settings]; + if (!ok) { + fprintf(stderr, "ERROR: Failed to apply display settings\n"); + return 1; + } + + printf("Virtual display created: %ux%u@60Hz (displayID: %u)\n", width, height, display.displayID); + printf("PID: %d\n", getpid()); + fflush(stdout); + + // Keep alive so the display persists + dispatch_main(); + } + return 0; +} diff --git a/scripts/download-prebuilt-ghosttykit.sh b/scripts/download-prebuilt-ghosttykit.sh new file mode 100755 index 00000000..cc3c520b --- /dev/null +++ b/scripts/download-prebuilt-ghosttykit.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [ -n "${GHOSTTY_SHA:-}" ]; then + GHOSTTY_SHA="$GHOSTTY_SHA" +else + if [ ! -d "$REPO_ROOT/ghostty" ] || ! git -C "$REPO_ROOT/ghostty" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Missing ghostty submodule. Run ./scripts/setup.sh or git submodule update --init --recursive first." >&2 + exit 1 + fi + GHOSTTY_SHA="$(git -C "$REPO_ROOT/ghostty" rev-parse HEAD)" +fi + +TAG="xcframework-$GHOSTTY_SHA" +ARCHIVE_NAME="${GHOSTTYKIT_ARCHIVE_NAME:-GhosttyKit.xcframework.tar.gz}" +OUTPUT_DIR="${GHOSTTYKIT_OUTPUT_DIR:-GhosttyKit.xcframework}" +CHECKSUMS_FILE="${GHOSTTYKIT_CHECKSUMS_FILE:-$SCRIPT_DIR/ghosttykit-checksums.txt}" +DOWNLOAD_URL="${GHOSTTYKIT_URL:-https://github.com/manaflow-ai/ghostty/releases/download/$TAG/$ARCHIVE_NAME}" +DOWNLOAD_RETRIES="${GHOSTTYKIT_DOWNLOAD_RETRIES:-30}" +DOWNLOAD_RETRY_DELAY="${GHOSTTYKIT_DOWNLOAD_RETRY_DELAY:-20}" + +if [ ! -f "$CHECKSUMS_FILE" ]; then + echo "Missing checksum file: $CHECKSUMS_FILE" >&2 + exit 1 +fi + +EXPECTED_SHA256="$( + awk -v sha="$GHOSTTY_SHA" ' + $1 == sha { + print $2 + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "$CHECKSUMS_FILE" || true +)" + +if [ -z "$EXPECTED_SHA256" ]; then + echo "Missing pinned GhosttyKit checksum for ghostty $GHOSTTY_SHA in $CHECKSUMS_FILE" >&2 + exit 1 +fi + +echo "Downloading $ARCHIVE_NAME for ghostty $GHOSTTY_SHA" +curl --fail --show-error --location \ + --retry "$DOWNLOAD_RETRIES" \ + --retry-delay "$DOWNLOAD_RETRY_DELAY" \ + --retry-all-errors \ + -o "$ARCHIVE_NAME" \ + "$DOWNLOAD_URL" + +ACTUAL_SHA256="$(shasum -a 256 "$ARCHIVE_NAME" | awk '{print $1}')" +if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then + echo "$ARCHIVE_NAME checksum mismatch" >&2 + echo "Expected: $EXPECTED_SHA256" >&2 + echo "Actual: $ACTUAL_SHA256" >&2 + exit 1 +fi + +rm -rf "$OUTPUT_DIR" +tar xzf "$ARCHIVE_NAME" +rm "$ARCHIVE_NAME" +test -d "$OUTPUT_DIR" + +echo "Verified and extracted $OUTPUT_DIR" diff --git a/scripts/generate_dark_icon.py b/scripts/generate_dark_icon.py new file mode 100755 index 00000000..b5ea13fe --- /dev/null +++ b/scripts/generate_dark_icon.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Generate dark mode app icon variants. + +Composites the Figma chevron layer (on transparent background) over a dark +squircle background derived from the light icon's alpha channel. This +preserves the exact chevron colors and glow without any halo artifacts. + +Requires the Figma export at: design/cmux-icon-chevron.png +Falls back to mathematical recomposition if the Figma layer is missing. +""" +import json +import os +import sys + +from PIL import Image, ImageFilter + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Apple systemBackground dark +DARK_BG = (28, 28, 30) + +# Figma chevron layer (exported from Figma at native resolution) +FIGMA_CHEVRON = os.path.join(REPO, "design", "cmux-icon-chevron.png") + +# The Figma export is ~25% larger than the repo icon. Scale and offset +# computed by matching the solid chevron (sat>0.5) bounding box center +# between the repo light icon and the scaled Figma chevron layer. +FIGMA_SCALE = 0.7996 +FIGMA_OFFSET = (290, 187) + +SIZES = [ + ("16.png", 16), + ("16@2x.png", 32), + ("32.png", 32), + ("32@2x.png", 64), + ("128.png", 128), + ("128@2x.png", 256), + ("256.png", 256), + ("256@2x.png", 512), + ("512.png", 512), + ("512@2x.png", 1024), +] + + +def make_dark_from_figma(light_1024: Image.Image, chevron: Image.Image) -> Image.Image: + """Create dark icon by compositing Figma chevron over dark background. + + Uses the light icon's alpha channel for the squircle shape mask, + fills it with the dark background color, then composites the + chevron layer on top at the precomputed offset. + """ + size = 1024 + light = light_1024.convert("RGBA") + if light.size != (size, size): + light = light.resize((size, size), Image.LANCZOS) + + # Create dark background with the squircle shape from the light icon + dark_bg = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + light_px = light.load() + dark_px = dark_bg.load() + for y in range(size): + for x in range(size): + _, _, _, a = light_px[x, y] + if a > 0: + dark_px[x, y] = (DARK_BG[0], DARK_BG[1], DARK_BG[2], a) + + # Scale chevron + chev = chevron.convert("RGBA") + cw, ch = chev.size + scaled_w = int(cw * FIGMA_SCALE) + scaled_h = int(ch * FIGMA_SCALE) + chev = chev.resize((scaled_w, scaled_h), Image.LANCZOS) + ox, oy = FIGMA_OFFSET + + # Build enhanced glow: brighten the chevron, blur at two radii + glow_src = chev.copy() + glow_px = glow_src.load() + for y in range(scaled_h): + for x in range(scaled_w): + r, g, b, a = glow_px[x, y] + if a > 0: + glow_px[x, y] = ( + min(255, int(r * 1.2)), + min(255, int(g * 1.2)), + min(255, int(b * 1.2)), + min(255, int(a * 1.1)), + ) + + glow_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + glow_canvas.paste(glow_src, (ox, oy), glow_src) + + # Wide soft glow + tighter glow + glow_wide = glow_canvas.filter(ImageFilter.GaussianBlur(radius=25)) + glow_tight = glow_canvas.filter(ImageFilter.GaussianBlur(radius=12)) + + # Reduce glow opacity + for glow, factor in [(glow_wide, 0.45), (glow_tight, 0.35)]: + gpx = glow.load() + for y in range(size): + for x in range(size): + r, g, b, a = gpx[x, y] + gpx[x, y] = (r, g, b, int(a * factor)) + + # Composite: dark bg -> wide glow -> tight glow -> sharp chevron + result = Image.alpha_composite(dark_bg, glow_wide) + result = Image.alpha_composite(result, glow_tight) + chev_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + chev_canvas.paste(chev, (ox, oy), chev) + result = Image.alpha_composite(result, chev_canvas) + + return result + + +def make_dark_fallback(img: Image.Image) -> Image.Image: + """Fallback: mathematical recomposition when Figma layer is unavailable.""" + img = img.convert("RGBA") + w, h = img.size + pixels = img.load() + + for y in range(h): + for x in range(w): + r, g, b, a = pixels[x, y] + if a == 0: + continue + max_dev = max(255 - r, 255 - g, 255 - b) + fg_alpha = min(max_dev / 60.0, 1.0) + bg_factor = 1.0 - fg_alpha + nr = max(0, int(r - bg_factor * (255 - DARK_BG[0]))) + ng = max(0, int(g - bg_factor * (255 - DARK_BG[1]))) + nb = max(0, int(b - bg_factor * (255 - DARK_BG[2]))) + pixels[x, y] = (nr, ng, nb, a) + + return img + + +def update_contents_json(icon_dir: str) -> None: + """Add dark appearance entries to Contents.json.""" + contents_path = os.path.join(icon_dir, "Contents.json") + with open(contents_path) as f: + contents = json.load(f) + + # Remove any existing dark entries to avoid duplicates + images = [ + img for img in contents["images"] + if not any( + ap.get("value") == "dark" + for ap in img.get("appearances", []) + ) + ] + + dark_images = [] + for img in images: + filename = img.get("filename", "") + if not filename: + continue + base, ext = os.path.splitext(filename) + dark_entry = { + "appearances": [ + {"appearance": "luminosity", "value": "dark"} + ], + "filename": f"{base}_dark{ext}", + "idiom": img["idiom"], + "scale": img["scale"], + "size": img["size"], + } + dark_images.append(dark_entry) + + # Interleave: light, dark, light, dark, ... + merged = [] + for i, img in enumerate(images): + merged.append(img) + if i < len(dark_images): + merged.append(dark_images[i]) + + contents["images"] = merged + with open(contents_path, "w") as f: + json.dump(contents, f, indent=2) + f.write("\n") + print(f" Updated {contents_path}") + + +def generate_dark_icons(icon_set: str) -> None: + """Generate dark variants for an icon set.""" + src_dir = os.path.join(REPO, "Assets.xcassets", f"{icon_set}.appiconset") + if not os.path.isdir(src_dir): + print(f"SKIP {icon_set} (not found)") + return + + use_figma = os.path.exists(FIGMA_CHEVRON) + if use_figma: + print(f"\n{icon_set} (using Figma chevron layer):") + chevron = Image.open(FIGMA_CHEVRON) + light_1024_path = os.path.join(src_dir, "512@2x.png") + light_1024 = Image.open(light_1024_path) + dark_1024 = make_dark_from_figma(light_1024, chevron) + else: + print(f"\n{icon_set} (fallback: mathematical recomposition):") + dark_1024 = None + + for filename, pixel_size in SIZES: + src_path = os.path.join(src_dir, filename) + if not os.path.exists(src_path): + print(f" SKIP {filename} (not found)") + continue + + base, ext = os.path.splitext(filename) + dst_path = os.path.join(src_dir, f"{base}_dark{ext}") + + if use_figma: + # Downscale the 1024x1024 Figma composite + dark_img = dark_1024.resize((pixel_size, pixel_size), Image.LANCZOS) + else: + img = Image.open(src_path) + if img.size != (pixel_size, pixel_size): + img = img.resize((pixel_size, pixel_size), Image.LANCZOS) + dark_img = make_dark_fallback(img) + + dark_img.save(dst_path, "PNG") + print(f" {base}_dark{ext} ({pixel_size}x{pixel_size})") + + update_contents_json(src_dir) + + +def main(): + generate_dark_icons("AppIcon") + + +if __name__ == "__main__": + main() diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt new file mode 100644 index 00000000..b3818784 --- /dev/null +++ b/scripts/ghosttykit-checksums.txt @@ -0,0 +1,6 @@ +# Pinned GhosttyKit.xcframework.tar.gz checksums keyed by ghostty submodule SHA. +# Update this file in a reviewed PR whenever the ghostty submodule SHA changes. +# Format: <ghostty_sha> <sha256> +7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 +a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d +0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de diff --git a/scripts/reload.sh b/scripts/reload.sh index 3cd2bb63..4e758a88 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -45,6 +45,11 @@ sanitize_path() { echo "$cleaned" } +tagged_derived_data_path() { + local slug="$1" + echo "$HOME/Library/Developer/Xcode/DerivedData/cmux-${slug}" +} + print_tag_cleanup_reminder() { local current_slug="$1" local path="" @@ -53,7 +58,13 @@ print_tag_cleanup_reminder() { local -a stale_tags=() while IFS= read -r -d '' path; do - tag="${path#/tmp/cmux-}" + if [[ "$path" == /tmp/cmux-* ]]; then + tag="${path#/tmp/cmux-}" + elif [[ "$path" == "$HOME/Library/Developer/Xcode/DerivedData/cmux-"* ]]; then + tag="${path#$HOME/Library/Developer/Xcode/DerivedData/cmux-}" + else + continue + fi if [[ "$tag" == "$current_slug" ]]; then continue fi @@ -66,7 +77,10 @@ print_tag_cleanup_reminder() { fi seen="${seen}${tag} " stale_tags+=("$tag") - done < <(find /tmp -maxdepth 1 -type d -name 'cmux-*' -print0 2>/dev/null) + done < <( + find /tmp -maxdepth 1 -name 'cmux-*' -print0 2>/dev/null + find "$HOME/Library/Developer/Xcode/DerivedData" -maxdepth 1 -type d -name 'cmux-*' -print0 2>/dev/null + ) echo echo "Tag cleanup status:" @@ -82,14 +96,14 @@ print_tag_cleanup_reminder() { echo "Cleanup stale tags only:" for tag in "${stale_tags[@]}"; do echo " pkill -f \"cmux DEV ${tag}.app/Contents/MacOS/cmux DEV\"" - echo " rm -rf \"/tmp/cmux-${tag}\" \"/tmp/cmux-debug-${tag}.sock\"" + echo " rm -rf \"$(tagged_derived_data_path "$tag")\" \"/tmp/cmux-${tag}\" \"/tmp/cmux-debug-${tag}.sock\"" echo " rm -f \"/tmp/cmux-debug-${tag}.log\"" echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${tag}.sock\"" done fi echo "After you verify current tag, cleanup command:" echo " pkill -f \"cmux DEV ${current_slug}.app/Contents/MacOS/cmux DEV\"" - echo " rm -rf \"/tmp/cmux-${current_slug}\" \"/tmp/cmux-debug-${current_slug}.sock\"" + echo " rm -rf \"$(tagged_derived_data_path "$current_slug")\" \"/tmp/cmux-${current_slug}\" \"/tmp/cmux-debug-${current_slug}.sock\"" echo " rm -f \"/tmp/cmux-debug-${current_slug}.log\"" echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${current_slug}.sock\"" } @@ -159,7 +173,7 @@ if [[ -n "$TAG" ]]; then BUNDLE_ID="com.cmuxterm.app.debug.${TAG_ID}" fi if [[ "$DERIVED_SET" -eq 0 ]]; then - DERIVED_DATA="/tmp/cmux-${TAG_SLUG}" + DERIVED_DATA="$(tagged_derived_data_path "$TAG_SLUG")" fi fi @@ -230,6 +244,15 @@ if [[ -z "${APP_PATH}" || ! -d "${APP_PATH}" ]]; then exit 1 fi +if [[ -n "${TAG_SLUG:-}" ]]; then + TMP_COMPAT_DERIVED_LINK="/tmp/cmux-${TAG_SLUG}" + if [[ "$DERIVED_DATA" != "$TMP_COMPAT_DERIVED_LINK" ]]; then + ABS_DERIVED_DATA="$(cd "$DERIVED_DATA" && pwd)" + rm -rf "$TMP_COMPAT_DERIVED_LINK" + ln -s "$ABS_DERIVED_DATA" "$TMP_COMPAT_DERIVED_LINK" + fi +fi + if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then TAG_APP_PATH="$(dirname "$APP_PATH")/${APP_NAME}.app" rm -rf "$TAG_APP_PATH" @@ -292,6 +315,10 @@ if [[ -x "$CMUXD_SRC" ]]; then cp "$CMUXD_SRC" "$BIN_DIR/cmuxd" chmod +x "$BIN_DIR/cmuxd" fi +CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" +if [[ -x "$CLI_PATH" ]]; then + echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true +fi # Avoid inheriting cmux/ghostty environment variables from the terminal that # runs this script (often inside another cmux instance), which can cause # socket and resource-path conflicts. @@ -318,14 +345,13 @@ OPEN_CLEAN_ENV=( if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then # Ensure tag-specific socket paths win even if the caller has CMUX_* overrides. - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" elif [[ -n "${TAG_SLUG:-}" ]]; then - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" else echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true - "${OPEN_CLEAN_ENV[@]}" open "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" open -g "$APP_PATH" fi -osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true # Safety: ensure only one instance is running. sleep 0.2 diff --git a/scripts/reloadp.sh b/scripts/reloadp.sh index fbb75fe8..62bc0597 100755 --- a/scripts/reloadp.sh +++ b/scripts/reloadp.sh @@ -17,5 +17,4 @@ if [[ -z "${APP_PATH}" ]]; then fi # Dev shells (including CI/Codex) often force-disable paging by exporting these. # Don't leak that into cmux, otherwise `git diff` won't page even with PAGER=less. -env -u GIT_PAGER -u GH_PAGER open "$APP_PATH" -osascript -e 'tell application "cmux" to activate' || true +env -u GIT_PAGER -u GH_PAGER open -g "$APP_PATH" diff --git a/scripts/reloads.sh b/scripts/reloads.sh index f2c2dfad..f06bc246 100755 --- a/scripts/reloads.sh +++ b/scripts/reloads.sh @@ -251,8 +251,7 @@ OPEN_CLEAN_ENV=( # Always inject staging socket paths via env to ensure they take effect # (LSEnvironment requires app restart to pick up plist changes). -"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH" -osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true +"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open -g "$APP_PATH" # Safety: ensure only one instance is running. sleep 0.2 diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh new file mode 100755 index 00000000..4d26c416 --- /dev/null +++ b/scripts/run-e2e.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Trigger the test-e2e.yml workflow and optionally wait for results. +# +# Usage: +# ./scripts/run-e2e.sh UpdatePillUITests +# ./scripts/run-e2e.sh UpdatePillUITests --wait +# ./scripts/run-e2e.sh UpdatePillUITests/testFoo --ref my-branch +# ./scripts/run-e2e.sh UpdatePillUITests --no-video --timeout 300 +set -euo pipefail + +REPO="manaflow-ai/cmux" +WORKFLOW="test-e2e.yml" + +# Defaults +REF="" +WAIT=false +RECORD_VIDEO=true +TIMEOUT=120 + +usage() { + cat <<EOF +Usage: $(basename "$0") <test_filter> [options] + +Arguments: + test_filter Test class or class/method (e.g. UpdatePillUITests) + +Options: + --ref <ref> Branch or SHA to test (default: current branch) + --wait Wait for the run to complete and print result + --no-video Disable video recording + --timeout <sec> Per-test timeout in seconds (default: 120) + -h, --help Show this help +EOF + exit 0 +} + +if [ $# -lt 1 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + usage +fi + +TEST_FILTER="$1" +shift + +while [ $# -gt 0 ]; do + case "$1" in + --ref) + REF="$2" + shift 2 + ;; + --wait) + WAIT=true + shift + ;; + --no-video) + RECORD_VIDEO=false + shift + ;; + --timeout) + TIMEOUT="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + usage + ;; + esac +done + +# Build workflow dispatch fields +FIELDS=(-f "test_filter=$TEST_FILTER" -f "record_video=$RECORD_VIDEO" -f "test_timeout=$TIMEOUT") +if [ -n "$REF" ]; then + FIELDS+=(-f "ref=$REF") +fi + +echo "Triggering $WORKFLOW with test_filter=$TEST_FILTER ref=${REF:-<default>} video=$RECORD_VIDEO timeout=$TIMEOUT" +gh workflow run "$WORKFLOW" --repo "$REPO" "${FIELDS[@]}" + +# Wait a moment for the run to register +sleep 3 + +# Get the latest run ID +RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" --limit 1 --json databaseId --jq '.[0].databaseId') +RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID" + +echo "Run: $RUN_URL" + +if [ "$WAIT" = true ]; then + echo "Waiting for run to complete..." + gh run watch --repo "$REPO" "$RUN_ID" --exit-status || true + + STATUS=$(gh run view --repo "$REPO" "$RUN_ID" --json conclusion --jq '.conclusion') + echo "" + echo "Result: $STATUS" + echo "Run: $RUN_URL" + + # Find the issue created for this run (search by run ID in body) + ISSUE_URL=$(gh search issues "$RUN_ID" --repo manaflow-ai/cmux-dev-artifacts --limit 1 --json url --jq '.[0].url' 2>/dev/null || true) + if [ -n "$ISSUE_URL" ]; then + echo "Issue: $ISSUE_URL" + fi +fi diff --git a/scripts/run-tests-v1.sh b/scripts/run-tests-v1.sh index 317d19cf..12592e70 100755 --- a/scripts/run-tests-v1.sh +++ b/scripts/run-tests-v1.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" +RUN_TAG="tests-v1" echo "== build ==" # Work around stale explicit-module cache artifacts (notably Sentry headers) that can @@ -51,7 +52,7 @@ launch_and_wait() { defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true # Launch directly with UI test mode enabled so startup follows deterministic test codepaths. - CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & + CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & SOCK="" for _ in {1..120}; do @@ -70,7 +71,7 @@ launch_and_wait() { export CMUX_SOCKET="$SOCK" # Ensure LaunchServices has a visible/main window attached for rendering checks. - open "$APP" >/dev/null 2>&1 || true + CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true sleep 0.5 echo "== wait ready ==" diff --git a/scripts/run-tests-v2.sh b/scripts/run-tests-v2.sh index 4be4e854..e17cc6c2 100755 --- a/scripts/run-tests-v2.sh +++ b/scripts/run-tests-v2.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" +RUN_TAG="tests-v2" echo "== build ==" # Work around stale explicit-module cache artifacts (notably Sentry headers) that can @@ -51,7 +52,7 @@ launch_and_wait() { defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true # Launch directly with UI test mode enabled so startup follows deterministic test codepaths. - CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & + CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & SOCK="" for _ in {1..120}; do @@ -70,7 +71,7 @@ launch_and_wait() { export CMUX_SOCKET="$SOCK" # Ensure LaunchServices has a visible/main window attached for rendering checks. - open "$APP" >/dev/null 2>&1 || true + CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true sleep 0.5 echo "== wait ready ==" diff --git a/scripts/setup.sh b/scripts/setup.sh index bcfeb818..7384ef62 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -58,7 +58,7 @@ else echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." ( cd ghostty - zig build -Demit-xcframework=true -Doptimize=ReleaseFast + zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast ) # Stamp the build output with the SHA it was built from echo "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP" diff --git a/scripts/smoke-test-ci.sh b/scripts/smoke-test-ci.sh new file mode 100755 index 00000000..60583bc5 --- /dev/null +++ b/scripts/smoke-test-ci.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Smoke test for CI: launch the app, send a command, verify it stays alive for 15 seconds. +set -euo pipefail + +SOCKET_PATH="/tmp/cmux-debug.sock" +STABILITY_WAIT=15 + +echo "=== Smoke Test ===" + +# --- Find the built app --- +APP=$(find ~/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit 2>/dev/null || true) +if [ -z "$APP" ]; then + echo "ERROR: Built app not found in DerivedData" + exit 1 +fi +echo "App: $APP" +BINARY="$APP/Contents/MacOS/cmux DEV" +if [ ! -x "$BINARY" ]; then + echo "ERROR: App binary not found or not executable: $BINARY" + exit 1 +fi + +# --- Clean up stale socket and any existing instances --- +rm -f "$SOCKET_PATH" +pkill -x "cmux DEV" 2>/dev/null || true +sleep 1 + +# --- Launch the app directly (not via `open`, which can silently fail on CI) --- +echo "Launching app..." +CMUX_SOCKET_MODE=allowAll CMUX_UI_TEST_MODE=1 "$BINARY" > /tmp/cmux-smoke-stdout.log 2>&1 & +APP_PID=$! +echo "App PID: $APP_PID" + +# --- Verify process is alive after 2s --- +sleep 2 +if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "ERROR: App exited immediately after launch" + echo "--- stdout/stderr ---" + cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -50 || true + echo "--- debug log ---" + tail -50 /tmp/cmux-debug.log 2>/dev/null || true + echo "--- crash reports ---" + ls -lt ~/Library/Logs/DiagnosticReports/*cmux* 2>/dev/null | head -5 || echo "(none)" + exit 1 +fi + +# --- Wait for socket (up to 30s) --- +echo "Waiting for socket at $SOCKET_PATH..." +SOCKET_READY=false +for i in $(seq 1 60); do + if [ -S "$SOCKET_PATH" ]; then + echo "Socket ready after $((i / 2))s" + SOCKET_READY=true + break + fi + # Check if process died while waiting + if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "ERROR: App crashed while waiting for socket" + echo "--- stdout/stderr ---" + cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -50 || true + echo "--- debug log ---" + tail -50 /tmp/cmux-debug.log 2>/dev/null || true + exit 1 + fi + sleep 0.5 +done +if [ "$SOCKET_READY" != "true" ]; then + echo "ERROR: Socket not ready after 30s" + echo "--- stdout/stderr ---" + cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -30 || true + echo "--- debug log ---" + tail -30 /tmp/cmux-debug.log 2>/dev/null || true + ls -la /tmp/cmux-debug* 2>/dev/null || true + pgrep -la "cmux" || echo "No cmux processes found" + exit 1 +fi + +# --- Ping the socket --- +echo "Pinging socket..." +PING_RESPONSE=$(python3 -c " +import socket +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect('$SOCKET_PATH') +s.settimeout(5.0) +s.sendall(b'ping\n') +data = s.recv(1024).decode().strip() +s.close() +print(data) +") +echo "Ping response: $PING_RESPONSE" +if [ "$PING_RESPONSE" != "PONG" ]; then + echo "ERROR: Expected PONG, got: $PING_RESPONSE" + exit 1 +fi + +# --- Send a command to the terminal --- +echo "Sending 'time' command to terminal..." +SEND_RESPONSE=$(python3 -c " +import socket +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect('$SOCKET_PATH') +s.settimeout(5.0) +s.sendall(b'send time\\\n\n') +data = s.recv(1024).decode().strip() +s.close() +print(data) +") +echo "Send response: $SEND_RESPONSE" + +# --- Wait and verify stability --- +echo "Waiting ${STABILITY_WAIT}s to verify stability..." +sleep "$STABILITY_WAIT" + +if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "ERROR: App crashed during ${STABILITY_WAIT}s stability check" + echo "--- stdout/stderr ---" + cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -30 || true + echo "--- debug log ---" + tail -30 /tmp/cmux-debug.log 2>/dev/null || true + exit 1 +fi + +# --- Final ping --- +FINAL_PING=$(python3 -c " +import socket +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect('$SOCKET_PATH') +s.settimeout(5.0) +s.sendall(b'ping\n') +data = s.recv(1024).decode().strip() +s.close() +print(data) +") +echo "Final ping: $FINAL_PING" +if [ "$FINAL_PING" != "PONG" ]; then + echo "ERROR: App not responsive after ${STABILITY_WAIT}s" + exit 1 +fi + +echo "=== Smoke test passed ===" + +# --- Cleanup --- +kill "$APP_PID" 2>/dev/null || true +wait "$APP_PID" 2>/dev/null || true diff --git a/scripts/sparkle_generate_appcast.sh b/scripts/sparkle_generate_appcast.sh index bfcfb64a..644562bd 100755 --- a/scripts/sparkle_generate_appcast.sh +++ b/scripts/sparkle_generate_appcast.sh @@ -70,14 +70,23 @@ while (( ${#padded_key} % 4 != 0 )); do done printf "%s" "$padded_key" > "$key_file" +generated_appcast_path="$archives_dir/$(basename "$OUT_PATH")" + "$generate_appcast" \ --ed-key-file "$key_file" \ --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ --full-release-notes-url "$RELEASE_NOTES_URL" \ "$archives_dir" -if [[ ! -f "$archives_dir/appcast.xml" ]]; then - echo "appcast.xml not generated." >&2 +if [[ ! -f "$generated_appcast_path" ]]; then + fallback_generated_appcast="$(find "$archives_dir" -maxdepth 1 -name '*.xml' | head -n 1)" + if [[ -n "$fallback_generated_appcast" ]]; then + generated_appcast_path="$fallback_generated_appcast" + fi +fi + +if [[ ! -f "$generated_appcast_path" ]]; then + echo "Expected appcast was not generated." >&2 exit 1 fi @@ -85,7 +94,7 @@ fi # to sign the DMG and inject the signature. generate_appcast silently skips # signing when the public key derived from the private key doesn't match the # SUPublicEDKey in the app's Info.plist. -if ! grep -q 'sparkle:edSignature' "$archives_dir/appcast.xml"; then +if ! grep -q 'sparkle:edSignature' "$generated_appcast_path"; then echo "Warning: generate_appcast did not add edSignature. Using sign_update fallback..." SIGNATURE=$("$sign_update" -p --ed-key-file "$key_file" "$DMG_PATH") DMG_LENGTH=$(stat -f%z "$DMG_PATH") @@ -95,7 +104,7 @@ if ! grep -q 'sparkle:edSignature' "$archives_dir/appcast.xml"; then # Inject sparkle:edSignature and correct length into the enclosure element python3 -c " import sys -xml = open('$archives_dir/appcast.xml').read() +xml = open('$generated_appcast_path').read() sig = '$SIGNATURE' length = '$DMG_LENGTH' # Add edSignature to enclosure @@ -103,12 +112,12 @@ xml = xml.replace( 'type=\"application/octet-stream\"', 'sparkle:edSignature=\"' + sig + '\" length=\"' + length + '\" type=\"application/octet-stream\"' ) -open('$archives_dir/appcast.xml', 'w').write(xml) +open('$generated_appcast_path', 'w').write(xml) print(' Injected edSignature into appcast.xml') " fi -cp "$archives_dir/appcast.xml" "$OUT_PATH" +cp "$generated_appcast_path" "$OUT_PATH" echo "Generated appcast at $OUT_PATH" # Verify the appcast has a signature diff --git a/skills/cmux-browser/SKILL.md b/skills/cmux-browser/SKILL.md index 8d398377..aed36c61 100644 --- a/skills/cmux-browser/SKILL.md +++ b/skills/cmux-browser/SKILL.md @@ -10,19 +10,21 @@ Use this skill for browser tasks inside cmux webviews. ## Core Workflow 1. Open or target a browser surface. -2. Snapshot (`--interactive`) to get fresh element refs. -3. Act with refs (`click`, `fill`, `type`, `select`, `press`). -4. Wait for state changes. -5. Re-snapshot after DOM/navigation changes. +2. Verify navigation with `get url` before waiting or snapshotting. +3. Snapshot (`--interactive`) to get fresh element refs. +4. Act with refs (`click`, `fill`, `type`, `select`, `press`). +5. Wait for state changes. +6. Re-snapshot after DOM/navigation changes. ```bash -cmux browser open https://example.com --json +cmux --json browser open https://example.com # use returned surface ref, for example: surface:7 +cmux browser surface:7 get url +cmux browser surface:7 wait --load-state complete --timeout-ms 15000 cmux browser surface:7 snapshot --interactive cmux browser surface:7 fill e1 "hello" -cmux browser surface:7 click e2 --snapshot-after --json -cmux browser surface:7 wait --load-state complete --timeout-ms 15000 +cmux --json browser surface:7 click e2 --snapshot-after cmux browser surface:7 snapshot --interactive ``` @@ -58,11 +60,13 @@ cmux browser <surface> wait --function "document.readyState === 'complete'" --ti ### Form Submit ```bash -cmux browser open https://example.com/signup --json +cmux --json browser open https://example.com/signup +cmux browser surface:7 get url +cmux browser surface:7 wait --load-state complete --timeout-ms 15000 cmux browser surface:7 snapshot --interactive cmux browser surface:7 fill e1 "Jane Doe" cmux browser surface:7 fill e2 "jane@example.com" -cmux browser surface:7 click e3 --snapshot-after --json +cmux --json browser surface:7 click e3 --snapshot-after cmux browser surface:7 wait --url-contains "/welcome" --timeout-ms 15000 cmux browser surface:7 snapshot --interactive ``` @@ -77,13 +81,16 @@ cmux browser surface:7 get value e11 --json ### Stable Agent Loop (Recommended) ```bash -# snapshot -> action -> wait -> snapshot -cmux browser surface:7 snapshot --interactive -cmux browser surface:7 click e5 --snapshot-after --json +# navigate -> verify -> wait -> snapshot -> action -> snapshot +cmux browser surface:7 get url cmux browser surface:7 wait --load-state complete --timeout-ms 15000 cmux browser surface:7 snapshot --interactive +cmux --json browser surface:7 click e5 --snapshot-after +cmux browser surface:7 snapshot --interactive ``` +If `get url` is empty or `about:blank`, navigate first instead of waiting on load state. + ## Deep-Dive References | Reference | When to Use | @@ -114,3 +121,21 @@ These commands currently return `not_supported` because they rely on Chrome/CDP- - low-level raw input injection Use supported high-level commands (`click`, `fill`, `press`, `scroll`, `wait`, `snapshot`) instead. + +## Troubleshooting + +### `js_error` on `snapshot --interactive` or `eval` + +Some complex pages can reject or break the JavaScript used for rich snapshots and ad-hoc evaluation. + +Recovery steps: + +```bash +cmux browser surface:7 get url +cmux browser surface:7 get text body +cmux browser surface:7 get html body +``` + +- Use `get url` first so you know whether the page actually navigated. +- Fall back to `get text body` or `get html body` when `snapshot --interactive` or `eval` returns `js_error`. +- If the page is still failing, navigate to a simpler intermediate page, then retry the task from there. diff --git a/skills/cmux-browser/references/commands.md b/skills/cmux-browser/references/commands.md index 5cc37625..72693a5d 100644 --- a/skills/cmux-browser/references/commands.md +++ b/skills/cmux-browser/references/commands.md @@ -11,7 +11,7 @@ This maps common `agent-browser` usage to `cmux browser` usage. - `agent-browser fill <ref> <text>` -> `cmux browser <surface> fill <ref> <text>` - `agent-browser type <ref> <text>` -> `cmux browser <surface> type <ref> <text>` - `agent-browser select <ref> <value>` -> `cmux browser <surface> select <ref> <value>` -- `agent-browser get text <ref>` -> `cmux browser <surface> get text <ref>` +- `agent-browser get text <ref>` -> `cmux browser <surface> get text <ref-or-selector>` - `agent-browser get url` -> `cmux browser <surface> get url` - `agent-browser get title` -> `cmux browser <surface> get title` @@ -34,7 +34,13 @@ cmux browser <surface> get url|title ```bash cmux browser <surface> snapshot --interactive cmux browser <surface> snapshot --interactive --compact --max-depth 3 -cmux browser <surface> get text|html|value|attr|count|box|styles ... +cmux browser <surface> get text body +cmux browser <surface> get html body +cmux browser <surface> get value "#email" +cmux browser <surface> get attr "#email" --attr placeholder +cmux browser <surface> get count ".row" +cmux browser <surface> get box "#submit" +cmux browser <surface> get styles "#submit" --property color cmux browser <surface> eval '<js>' ``` diff --git a/skills/cmux-browser/templates/authenticated-session.sh b/skills/cmux-browser/templates/authenticated-session.sh index bf19a274..284b77af 100755 --- a/skills/cmux-browser/templates/authenticated-session.sh +++ b/skills/cmux-browser/templates/authenticated-session.sh @@ -10,6 +10,7 @@ if [ -f "$STATE_FILE" ]; then fi cmux browser "$SURFACE" goto "$DASHBOARD_URL" +cmux browser "$SURFACE" get url cmux browser "$SURFACE" wait --load-state complete --timeout-ms 15000 cmux browser "$SURFACE" snapshot --interactive diff --git a/skills/cmux-browser/templates/form-automation.sh b/skills/cmux-browser/templates/form-automation.sh index f8a9406c..0c50d15e 100755 --- a/skills/cmux-browser/templates/form-automation.sh +++ b/skills/cmux-browser/templates/form-automation.sh @@ -5,6 +5,7 @@ URL="${1:-https://example.com/form}" SURFACE="${2:-surface:1}" cmux browser "$SURFACE" goto "$URL" +cmux browser "$SURFACE" get url cmux browser "$SURFACE" wait --load-state complete --timeout-ms 15000 cmux browser "$SURFACE" snapshot --interactive diff --git a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh index ac08502d..7798e8e7 100755 --- a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh +++ b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh @@ -93,6 +93,7 @@ sidebarBlurOpacity="$(format_number "$(read_value sidebarBlurOpacity 0.79)" 2)" sidebarTintHex="$(read_value sidebarTintHex '#101010')" sidebarTintOpacity="$(format_number "$(read_value sidebarTintOpacity 0.54)" 2)" sidebarCornerRadius="$(format_number "$(read_value sidebarCornerRadius 0.0)" 1)" +sidebarActiveTabIndicatorStyle="$(read_value sidebarActiveTabIndicatorStyle solidFill)" shortcutHintSidebarXOffset="$(format_number "$(read_value shortcutHintSidebarXOffset 0.0)" 1)" shortcutHintSidebarYOffset="$(format_number "$(read_value shortcutHintSidebarYOffset 0.0)" 1)" shortcutHintTitlebarXOffset="$(format_number "$(read_value shortcutHintTitlebarXOffset 4.0)" 1)" @@ -141,6 +142,7 @@ sidebarBlurOpacity=$sidebarBlurOpacity sidebarTintHex=$sidebarTintHex sidebarTintOpacity=$sidebarTintOpacity sidebarCornerRadius=$sidebarCornerRadius +sidebarActiveTabIndicatorStyle=$sidebarActiveTabIndicatorStyle shortcutHintSidebarXOffset=$shortcutHintSidebarXOffset shortcutHintSidebarYOffset=$shortcutHintSidebarYOffset shortcutHintTitlebarXOffset=$shortcutHintTitlebarXOffset diff --git a/skills/cmux-markdown/SKILL.md b/skills/cmux-markdown/SKILL.md new file mode 100644 index 00000000..8d2aac73 --- /dev/null +++ b/skills/cmux-markdown/SKILL.md @@ -0,0 +1,125 @@ +--- +name: cmux-markdown +description: Open markdown files in a formatted viewer panel with live reload. Use when you need to display plans, documentation, or notes alongside the terminal with rich rendering (headings, code blocks, tables, lists). +--- + +# Markdown Viewer with cmux + +Use this skill to display markdown files in a dedicated panel with rich formatting and live file watching. + +## Core Workflow + +1. Write your plan or notes to a `.md` file. +2. Open it in a markdown panel. +3. The panel auto-updates when the file changes on disk. + +```bash +# Open a markdown file as a split panel next to the current terminal +cmux markdown open plan.md + +# Absolute path +cmux markdown open /path/to/PLAN.md + +# Target a specific workspace +cmux markdown open design.md --workspace workspace:2 +``` + +## When to Use + +- Displaying an agent plan or task list alongside the terminal +- Showing documentation, changelogs, or READMEs while working +- Reviewing notes that update in real-time (e.g., a plan file being written by another process) + +## Live File Watching + +The panel automatically re-renders when the file changes on disk. This works with: + +- Direct writes (`echo "..." >> plan.md`) +- Editor saves (vim, nano, VS Code) +- Atomic file replacement (write to temp, rename over original) +- Agent-generated plan files that are updated progressively + +If the file is deleted, the panel shows a "file unavailable" state. During atomic replace, the panel attempts automatic reconnection within its short retry window. If the file returns later, close and reopen the panel. + +## Agent Integration + +### Opening a plan file + +Write your plan to a file, then open it: + +```bash +cat > plan.md << 'EOF' +# Task Plan + +## Steps +1. Analyze the codebase +2. Implement the feature +3. Write tests +4. Verify the build +EOF + +cmux markdown open plan.md +``` + +### Updating a plan in real-time + +The panel live-reloads, so simply overwrite the file as work progresses: + +```bash +# The markdown panel updates automatically when the file changes +echo "## Step 1: Complete" >> plan.md +``` + +### Recommended AGENTS.md instruction + +Add this to your project's `AGENTS.md` to instruct coding agents to use the markdown viewer: + +```markdown +## Plan Display + +When creating a plan or task list, write it to a `.md` file and open it in cmux: + + cmux markdown open plan.md + +The panel renders markdown with rich formatting and auto-updates when the file changes. +``` + +## Routing + +```bash +# Open in the caller's workspace (default -- uses CMUX_WORKSPACE_ID) +cmux markdown open plan.md + +# Open in a specific workspace +cmux markdown open plan.md --workspace workspace:2 + +# Open splitting from a specific surface +cmux markdown open plan.md --surface surface:5 + +# Open in a specific window +cmux markdown open plan.md --window window:1 +``` + +## Deep-Dive References + +| Reference | When to Use | +|-----------|-------------| +| [references/commands.md](references/commands.md) | Full command syntax and options | +| [references/live-reload.md](references/live-reload.md) | File watching behavior, atomic writes, edge cases | + +## Rendering Support + +The markdown panel renders: + +- Headings (h1-h6) with dividers on h1/h2 +- Fenced code blocks with monospaced font +- Inline code with highlighted background +- Tables with alternating row colors +- Ordered and unordered lists (nested) +- Blockquotes with left border +- Bold, italic, strikethrough +- Links (clickable) +- Horizontal rules +- Images (inline) + +Supports both light and dark mode. diff --git a/skills/cmux-markdown/agents/openai.yaml b/skills/cmux-markdown/agents/openai.yaml new file mode 100644 index 00000000..0ce42fe4 --- /dev/null +++ b/skills/cmux-markdown/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "cmux Markdown Viewer" + short_description: "Open markdown files in a formatted panel with live reload alongside the terminal." + default_prompt: "Use this skill to display markdown plans, docs, or notes in a cmux panel: write to a .md file, run 'cmux markdown open <path>', and the panel auto-updates when the file changes." diff --git a/skills/cmux-markdown/references/commands.md b/skills/cmux-markdown/references/commands.md new file mode 100644 index 00000000..f40f635d --- /dev/null +++ b/skills/cmux-markdown/references/commands.md @@ -0,0 +1,69 @@ +# Command Reference (cmux Markdown) + +## Opening a Markdown Panel + +```bash +cmux markdown open <path> +cmux markdown <path> # shorthand (implicit "open") +``` + +### Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--workspace <id\|ref\|index>` | Target workspace | `$CMUX_WORKSPACE_ID` | +| `--surface <id\|ref\|index>` | Source surface to split from | Focused surface | +| `--window <id\|ref>` | Target window | Current window | + +### Output + +``` +OK surface=surface:8 pane=pane:3 path=/absolute/path/to/file.md +``` + +With `--json`: + +```json +{ + "window_id": "...", + "workspace_id": "...", + "pane_id": "...", + "surface_id": "...", + "path": "/absolute/path/to/file.md" +} +``` + +## Path Resolution + +- Relative paths are resolved against the caller's current working directory. +- `~` is expanded to the home directory. +- The resolved absolute path is returned in the output. + +```bash +# These are equivalent when run from /Users/me/project +cmux markdown open plan.md +cmux markdown open ./plan.md +cmux markdown open /Users/me/project/plan.md +``` + +## Panel Behavior + +- The panel opens as a **horizontal split** to the right of the source surface. +- The tab title shows the filename (e.g., `plan.md`). +- The tab icon is a document icon. +- Content is **read-only** with text selection enabled. +- The file path is displayed as a breadcrumb at the top of the panel. + +## Session Persistence + +Markdown panels are saved and restored across sessions. On restore, the panel re-reads the file from disk. If the file no longer exists at restore time, the panel is not recreated. + +## Help + +```bash +cmux markdown --help +cmux markdown -h +``` + +See also: +- [live-reload.md](live-reload.md) diff --git a/skills/cmux-markdown/references/live-reload.md b/skills/cmux-markdown/references/live-reload.md new file mode 100644 index 00000000..ca0ba724 --- /dev/null +++ b/skills/cmux-markdown/references/live-reload.md @@ -0,0 +1,53 @@ +# Live Reload Behavior + +The markdown panel watches the file on disk and automatically re-renders when it changes. This enables real-time plan tracking as agents or editors update the file. + +## How It Works + +The panel uses a kernel-level file system watcher (`DispatchSource` with `O_EVTONLY`) that monitors the file for: + +- **Write events** -- content was modified in place +- **Extend events** -- content was appended +- **Delete events** -- file was removed (atomic replace step 1) +- **Rename events** -- file was moved or renamed + +## Supported Write Patterns + +| Pattern | Supported | Notes | +|---------|-----------|-------| +| Direct write (`echo >>`) | Yes | Triggers write/extend event | +| Editor save (vim, nano) | Yes | Most editors use atomic write (see below) | +| Atomic replace (write tmp + rename) | Yes | Handled via delete/rename recovery | +| `sed -i` | Yes | Uses atomic replace internally | +| VS Code / IDE save | Yes | Uses atomic replace | +| Agent progressive writes | Yes | Each write triggers a re-render | + +## Atomic File Replacement + +Many editors and tools write files atomically: write to a temporary file, then rename it over the original. This shows up as a **delete** event followed by a new file appearing at the same path. + +The panel handles this by: + +1. Detecting the delete/rename event +2. Attempting to re-read the file immediately (in case the rename already happened) +3. If the file is missing, wait 500 ms and check again (the new file may not yet be in place) +4. Reconnecting the file watcher to the new inode + +## File Unavailable State + +If the file is deleted and does not reappear within the retry window, the panel shows a "file unavailable" state with the original path. The panel does not close automatically -- the user must close it manually. + +If the file later reappears at the same path (e.g., the user recreates it), the panel does NOT automatically reconnect. Close and reopen the panel to pick up the new file. + +## Performance + +- Re-reads are dispatched to the main thread and run synchronously. +- Large files (100KB+) may cause brief UI hitches during re-render. For extremely large markdown files, consider splitting into smaller documents. +- The file watcher runs on a low-priority background queue and has negligible CPU impact. + +## Tips for Agents + +- **Write the full plan file first, then open it.** This avoids the panel showing a partially written file. +- **Append-style updates work well.** Adding sections to the end of a file triggers a smooth re-render. +- **Overwriting the entire file is fine.** The atomic replace handling ensures no data is lost. +- **Don't delete and recreate rapidly.** If writing a new version, prefer overwriting in place or using atomic replacement. diff --git a/skills/cmux/SKILL.md b/skills/cmux/SKILL.md index 336102d0..515315cc 100644 --- a/skills/cmux/SKILL.md +++ b/skills/cmux/SKILL.md @@ -51,3 +51,4 @@ cmux trigger-flash --surface surface:7 | [references/panes-surfaces.md](references/panes-surfaces.md) | Splits, surfaces, move/reorder, focus routing | | [references/trigger-flash-and-health.md](references/trigger-flash-and-health.md) | Flash cue and surface health checks | | [../cmux-browser/SKILL.md](../cmux-browser/SKILL.md) | Browser automation on surface-backed webviews | +| [../cmux-markdown/SKILL.md](../cmux-markdown/SKILL.md) | Markdown viewer panel with live file watching | diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 0f5cd1ff..d48cd0da 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -16,15 +16,18 @@ Run this workflow to prepare and publish a cmux release. 2. Create a release branch: - `git checkout -b release/vX.Y.Z` -3. Gather user-facing changes since the last tag: +3. Gather user-facing changes and contributors since the last tag: - `git describe --tags --abbrev=0` - `git log --oneline <last-tag>..HEAD --no-merges` - Keep only end-user visible changes (features, bug fixes, UX/perf behavior). +- **Collect contributors:** For each PR, get the author with `gh pr view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'`. Also check linked issue reporters with `gh issue view <N> --json author --jq '.author.login'`. +- Build a deduplicated list of all contributor `@handle`s. 4. Update changelogs: - Update `CHANGELOG.md`. -- Update `docs-site/content/docs/changelog.mdx`. +- Do not edit a separate docs changelog file; `web/app/docs/changelog/page.tsx` renders from `CHANGELOG.md`. - Use categories `Added`, `Changed`, `Fixed`, `Removed`. +- **Credit contributors inline** (see Contributor Credits below). - If no user-facing changes exist, confirm with the user before continuing. 5. Bump app version metadata: @@ -63,3 +66,11 @@ Run this workflow to prepare and publish a cmux release. - Include only user-visible changes. - Exclude internal-only changes (CI, tests, docs-only edits, refactors without behavior changes). - Write concise user-facing bullets in present tense. + +## Contributor Credits + +Credit the people who made each release happen: + +- **Per-entry:** Append `— thanks @user!` for community code contributions. Use `— thanks @user for the report!` for bug reporters (when different from PR author). No callout for core team (`lawrencecchen`, `austinywang`) — core work is the baseline. +- **Summary:** Add a `### Thanks to N contributors!` section at the bottom of each release with an alphabetical list of all `[@handle](https://github.com/handle)` links (including core team). +- **GitHub Release body:** Include the same "Thanks to N contributors!" section with linked handles. diff --git a/tests/claude_teams_test_utils.py b/tests/claude_teams_test_utils.py new file mode 100644 index 00000000..ab5e42e9 --- /dev/null +++ b/tests/claude_teams_test_utils.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +from pathlib import Path + + +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 + + recorded_path = Path("/tmp/cmux-last-cli-path") + if recorded_path.exists(): + candidate = recorded_path.read_text(encoding="utf-8").strip() + if candidate and os.path.exists(candidate) and os.access(candidate, os.X_OK): + return candidate + + raise RuntimeError( + "Unable to find cmux CLI binary. Set CMUX_CLI_BIN or run ./scripts/reload.sh --tag <tag> first." + ) diff --git a/tests/cmux.py b/tests/cmux.py index 23c1f4b7..c4f95904 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -500,7 +500,17 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) - def set_status(self, key: str, value: str, icon: str = None, color: str = None, tab: str = None) -> None: + def set_status( + self, + key: str, + value: str, + icon: str = None, + color: str = None, + url: str = None, + priority: int = None, + format: str = None, + tab: str = None, + ) -> None: """Set a sidebar status entry.""" # Put options before `--` so value can contain arbitrary tokens like `--tab`. cmd = f"set_status {key}" @@ -508,6 +518,12 @@ class cmux: cmd += f" --icon={icon}" if color: cmd += f" --color={color}" + if url: + cmd += f" --url={_quote_option_value(url)}" + if priority is not None: + cmd += f" --priority={priority}" + if format: + cmd += f" --format={format}" if tab: cmd += f" --tab={tab}" cmd += f" -- {_quote_option_value(value)}" @@ -524,6 +540,86 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def report_meta( + self, + key: str, + value: str, + icon: str = None, + color: str = None, + url: str = None, + priority: int = None, + format: str = None, + tab: str = None, + ) -> None: + """Report a sidebar metadata entry.""" + cmd = f"report_meta {key}" + if icon: + cmd += f" --icon={icon}" + if color: + cmd += f" --color={color}" + if url: + cmd += f" --url={_quote_option_value(url)}" + if priority is not None: + cmd += f" --priority={priority}" + if format: + cmd += f" --format={format}" + if tab: + cmd += f" --tab={tab}" + cmd += f" -- {_quote_option_value(value)}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_meta(self, key: str, tab: str = None) -> None: + """Remove a sidebar metadata entry.""" + cmd = f"clear_meta {key}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def list_meta(self, tab: str = None) -> str: + """List sidebar metadata entries.""" + cmd = "list_meta" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if response.startswith("ERROR"): + raise cmuxError(response) + return response + + def report_meta_block(self, key: str, markdown: str, priority: int = None, tab: str = None) -> None: + """Report a freeform sidebar markdown metadata block.""" + cmd = f"report_meta_block {key}" + if priority is not None: + cmd += f" --priority={priority}" + if tab: + cmd += f" --tab={tab}" + cmd += f" -- {_quote_option_value(markdown)}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_meta_block(self, key: str, tab: str = None) -> None: + """Remove a sidebar markdown metadata block.""" + cmd = f"clear_meta_block {key}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def list_meta_blocks(self, tab: str = None) -> str: + """List sidebar markdown metadata blocks.""" + cmd = "list_meta_blocks" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if response.startswith("ERROR"): + raise cmuxError(response) + return response + def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None: """Append a sidebar log entry.""" # TerminalController.parseOptions treats any --* token as an option until @@ -572,6 +668,63 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def report_pr( + self, + number: int, + url: str, + label: str = None, + state: str = None, + tab: str = None, + panel: str = None, + ) -> None: + """Report pull-request metadata for sidebar display.""" + cmd = f"report_pr {number} {url}" + if label: + cmd += f" --label={_quote_option_value(label)}" + if state: + cmd += f" --state={state}" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def report_review( + self, + number: int, + url: str, + label: str = None, + state: str = None, + tab: str = None, + panel: str = None, + ) -> None: + """Report provider-specific review metadata (GitLab MR, Bitbucket PR, etc.).""" + cmd = f"report_review {number} {url}" + if label: + cmd += f" --label={_quote_option_value(label)}" + if state: + cmd += f" --state={state}" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_pr(self, tab: str = None, panel: str = None) -> None: + """Clear pull-request metadata for sidebar display.""" + cmd = "clear_pr" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + def report_ports(self, *ports: int, tab: str = None) -> None: """Report listening ports for sidebar display.""" port_str = " ".join(str(p) for p in ports) diff --git a/tests/regression_helpers.py b/tests/regression_helpers.py new file mode 100644 index 00000000..73965c51 --- /dev/null +++ b/tests/regression_helpers.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Shared helpers for static regression tests.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + git = shutil.which("git") + if git is None: + return Path(__file__).resolve().parents[1] + try: + result = subprocess.run( + [git, "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + timeout=2, + ) + except (subprocess.TimeoutExpired, OSError): + return Path(__file__).resolve().parents[1] + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + # Targeted helper for this regression suite: assumes braces in the matched + # block are structural (not inside strings/comments/character literals). + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + + raise ValueError(f"Unbalanced braces for: {signature}") diff --git a/tests/test_browser_devtools_portal_regressions.py b/tests/test_browser_devtools_portal_regressions.py deleted file mode 100644 index 6ec27096..00000000 --- a/tests/test_browser_devtools_portal_regressions.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression checks for browser DevTools/portal review fixes. - -Guards two follow-up fixes: -1) DevTools toggle path must retry restore when inspector show is transiently ignored. -2) Browser portal visibility must propagate even if host is temporarily off-window. -""" - -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 main() -> int: - root = repo_root() - failures: list[str] = [] - - panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift" - panel_source = panel_path.read_text(encoding="utf-8") - toggle_block = extract_block(panel_source, "func toggleDeveloperTools() -> Bool") - if "visibleAfterToggle" not in toggle_block: - failures.append("toggleDeveloperTools() no longer re-checks inspector visibility") - if "scheduleDeveloperToolsRestoreRetry()" not in toggle_block: - failures.append("toggleDeveloperTools() no longer schedules a DevTools restore retry") - - view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" - view_source = view_path.read_text(encoding="utf-8") - portal_update_block = extract_block(view_source, "private func updateUsingWindowPortal(") - if "BrowserWindowPortalRegistry.updateEntryVisibility(" not in portal_update_block: - failures.append("BrowserPanelView.updateUsingWindowPortal() is missing deferred portal visibility propagation") - if "zPriority: coordinator.desiredPortalZPriority" not in portal_update_block: - failures.append("BrowserPanelView deferred portal update no longer propagates zPriority") - - portal_path = root / "Sources" / "BrowserWindowPortal.swift" - portal_source = portal_path.read_text(encoding="utf-8") - if not re.search( - r"func\s+updateEntryVisibility\s*\(\s*forWebViewId\s+webViewId:\s*ObjectIdentifier,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)", - portal_source, - flags=re.MULTILINE, - ): - failures.append("WindowBrowserPortal is missing updateEntryVisibility(forWebViewId:visibleInUI:zPriority:)") - if not re.search( - r"static\s+func\s+updateEntryVisibility\s*\(\s*for\s+webView:\s*WKWebView,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)", - portal_source, - flags=re.MULTILINE, - ): - failures.append("BrowserWindowPortalRegistry is missing updateEntryVisibility(for:visibleInUI:zPriority:)") - - if failures: - print("FAIL: browser devtools/portal regression guards failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: browser devtools/portal regression guards are in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_browser_favicon_navigation_regression.py b/tests/test_browser_favicon_navigation_regression.py deleted file mode 100644 index 61603e1d..00000000 --- a/tests/test_browser_favicon_navigation_regression.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression checks for favicon sync during browser navigation. - -Guards the race fix where stale async favicon fetches must not overwrite the -icon after the user navigates (including back/forward and same-URL reloads), -while still allowing same-document URL changes (pushState/hash updates). -""" - -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] = [] - - panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift" - panel_source = panel_path.read_text(encoding="utf-8") - - if "private var faviconRefreshGeneration: Int = 0" not in panel_source: - failures.append("BrowserPanel is missing faviconRefreshGeneration state") - - refresh_block = extract_block(panel_source, "private func refreshFavicon(from webView: WKWebView)") - if refresh_block.count("isCurrentFaviconRefresh(") < 3: - failures.append("refreshFavicon() no longer checks staleness at each async stage") - - current_guard_block = extract_block(panel_source, "private func isCurrentFaviconRefresh(") - if "generation == faviconRefreshGeneration" not in current_guard_block: - failures.append("isCurrentFaviconRefresh() no longer validates refresh generation") - if "webView.url?.absoluteString == pageURLString" in current_guard_block: - failures.append("isCurrentFaviconRefresh() still blocks same-document history URL changes") - - loading_block = extract_block(panel_source, "private func handleWebViewLoadingChanged(_ newValue: Bool)") - if "faviconRefreshGeneration &+= 1" not in loading_block: - failures.append("handleWebViewLoadingChanged() no longer invalidates old favicon refreshes") - if "faviconTask?.cancel()" not in loading_block: - failures.append("handleWebViewLoadingChanged() no longer cancels stale favicon tasks") - if "lastFaviconURLString = nil" not in loading_block: - failures.append("handleWebViewLoadingChanged() no longer resets favicon URL cache on new loads") - - if failures: - print("FAIL: browser favicon navigation regression guard failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: browser favicon navigation guard is in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_browser_new_tab_surface_focus_omnibar.py b/tests/test_browser_new_tab_surface_focus_omnibar.py new file mode 100644 index 00000000..cea196c5 --- /dev/null +++ b/tests/test_browser_new_tab_surface_focus_omnibar.py @@ -0,0 +1,455 @@ +#!/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 should list only workspaces, then switching to a workspace with a + focused blank browser 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 should only list workspaces, and switching to a workspace + # that has a focused blank browser should focus the omnibar. + target_workspace_id = client.new_workspace() + workspace_ids.append(target_workspace_id) + client.select_workspace(target_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 (target setup)") + + switcher_browser_id = client.new_surface(panel_type="browser") + time.sleep(0.3) + client.focus_surface_by_panel(switcher_browser_id) + + did_focus_target_browser = wait_for( + lambda: bool( + browser_address_bar_focus_state( + client, + surface_id=switcher_browser_id, + request_id="browser-focus-switcher-target-setup" + ).get("focused") + ), + timeout_s=3.0, + interval_s=0.1 + ) + if not did_focus_target_browser: + raise cmuxError("Failed to focus omnibar on target workspace browser before Cmd+P switch") + + source_workspace_id = client.new_workspace() + workspace_ids.append(source_workspace_id) + client.select_workspace(source_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 (source setup)") + + source_surfaces = client.list_surfaces() + source_terminal_id = next((surface_id for _, surface_id, _ in source_surfaces), None) + if not source_terminal_id: + raise cmuxError("Missing terminal surface for Cmd+P workspace switcher scenario") + client.focus_surface_by_panel(source_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") + + switcher_results = command_palette_results(client, window_id, limit=100) + switcher_ids = [row.get("command_id") for row in switcher_results if isinstance(row.get("command_id"), str)] + has_surface_rows = any(command_id.startswith("switcher.surface.") for command_id in switcher_ids) + if has_surface_rows: + raise cmuxError("Cmd+P switcher listed unexpected surface rows; expected workspace-only results") + + target_command_id = f"switcher.workspace.{target_workspace_id.lower()}" + 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 workspace 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 workspace switch did not restore blank browser omnibar focus") + + # Scenario 5: Cmd+P switcher should dismiss on Escape reliably. + client.select_workspace(source_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 5") + + client.focus_surface_by_panel(source_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-escape" + ).get("visible") + ), + timeout_s=2.0, + interval_s=0.1 + ): + raise cmuxError("Cmd+P did not open command palette switcher before Escape scenario") + + client.simulate_shortcut("escape") + did_dismiss_switcher_on_escape = wait_for( + lambda: not bool( + v2_call( + client, + "debug.command_palette.visible", + {"window_id": window_id}, + request_id="palette-visible-switcher-after-escape" + ).get("visible") + ), + timeout_s=3.0, + interval_s=0.1 + ) + if not did_dismiss_switcher_on_escape: + raise cmuxError("Cmd+P Escape did not dismiss command palette switcher") + + print("PASS: blank-browser focus paths (surface, pane, Cmd+P Enter switcher, and Cmd+P Escape dismiss) 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()) diff --git a/tests/test_browser_omnibar_compact_layout_regression.py b/tests/test_browser_omnibar_compact_layout_regression.py deleted file mode 100644 index 3886f495..00000000 --- a/tests/test_browser_omnibar_compact_layout_regression.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/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" - ) - - 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") - - 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()) diff --git a/tests/test_ci_create_dmg_pinned.sh b/tests/test_ci_create_dmg_pinned.sh new file mode 100755 index 00000000..1199f699 --- /dev/null +++ b/tests/test_ci_create_dmg_pinned.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Regression test for https://github.com/manaflow-ai/cmux/issues/387. +# Ensures release workflows pin create-dmg to an explicit version. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +WORKFLOWS=( + "$ROOT_DIR/.github/workflows/release.yml" + "$ROOT_DIR/.github/workflows/nightly.yml" +) + +for workflow in "${WORKFLOWS[@]}"; do + if ! grep -Eq 'npm install --global .*create-dmg@' "$workflow"; then + echo "FAIL: $workflow must install create-dmg with an explicit version" + exit 1 + fi + + if grep -Eq 'npm install --global[[:space:]]+create-dmg([[:space:]]|$)' "$workflow"; then + echo "FAIL: $workflow still has unpinned create-dmg install" + exit 1 + fi +done + +echo "PASS: create-dmg install is pinned in release workflows" diff --git a/tests/test_ci_ghosttykit_checksum_verification.sh b/tests/test_ci_ghosttykit_checksum_verification.sh new file mode 100755 index 00000000..1eba6ecf --- /dev/null +++ b/tests/test_ci_ghosttykit_checksum_verification.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Regression test for the pinned GhosttyKit artifact verification helper. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/scripts/download-prebuilt-ghosttykit.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +WORKFLOWS=( + "$ROOT_DIR/.github/workflows/ci.yml" + "$ROOT_DIR/.github/workflows/nightly.yml" + "$ROOT_DIR/.github/workflows/release.yml" +) + +FIXTURE_SHA="7dd589824d4c9bda8265355718800cccaf7189a0" +FIXTURE_DIR="$TMP_DIR/fixture" +SUCCESS_DIR="$TMP_DIR/success" +MISMATCH_DIR="$TMP_DIR/mismatch" +MISSING_ENTRY_DIR="$TMP_DIR/missing-entry" +BIN_DIR="$TMP_DIR/bin" +CHECKSUMS_FILE="$TMP_DIR/ghosttykit-checksums.txt" +SUCCESS_LOG="$TMP_DIR/curl-success.log" +MISMATCH_LOG="$TMP_DIR/curl-mismatch.log" +MISMATCH_OUTPUT="$TMP_DIR/mismatch.out" +MISSING_ENTRY_OUTPUT="$TMP_DIR/missing-entry.out" + +mkdir -p "$FIXTURE_DIR/GhosttyKit.xcframework" "$SUCCESS_DIR" "$MISMATCH_DIR" "$MISSING_ENTRY_DIR" "$BIN_DIR" +printf 'fixture\n' > "$FIXTURE_DIR/GhosttyKit.xcframework/marker.txt" +(cd "$FIXTURE_DIR" && tar czf "$TMP_DIR/GhosttyKit.xcframework.tar.gz" GhosttyKit.xcframework) +ACTUAL_SHA256="$(shasum -a 256 "$TMP_DIR/GhosttyKit.xcframework.tar.gz" | awk '{print $1}')" +printf '%s %s\n' "$FIXTURE_SHA" "$ACTUAL_SHA256" > "$CHECKSUMS_FILE" + +for workflow in "${WORKFLOWS[@]}"; do + if ! grep -Fq './scripts/download-prebuilt-ghosttykit.sh' "$workflow"; then + echo "FAIL: $workflow must call download-prebuilt-ghosttykit.sh" + exit 1 + fi +done + +cat > "$BIN_DIR/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +LOG_FILE="${TEST_CURL_LOG:?}" +FIXTURE_ARCHIVE="${TEST_FIXTURE_ARCHIVE:?}" +OUTPUT="" + +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + OUTPUT="$2" + shift 2 + ;; + *) + printf '%s\n' "$1" >> "$LOG_FILE" + shift + ;; + esac +done + +if [ -z "$OUTPUT" ]; then + echo "curl stub missing -o output path" >&2 + exit 1 +fi + +cp "$FIXTURE_ARCHIVE" "$OUTPUT" +EOF +chmod +x "$BIN_DIR/curl" + +( + cd "$SUCCESS_DIR" + PATH="$BIN_DIR:$PATH" \ + TEST_CURL_LOG="$SUCCESS_LOG" \ + TEST_FIXTURE_ARCHIVE="$TMP_DIR/GhosttyKit.xcframework.tar.gz" \ + GHOSTTY_SHA="$FIXTURE_SHA" \ + GHOSTTYKIT_CHECKSUMS_FILE="$CHECKSUMS_FILE" \ + "$SCRIPT" +) + +if [ ! -f "$SUCCESS_DIR/GhosttyKit.xcframework/marker.txt" ]; then + echo "FAIL: verification helper did not extract GhosttyKit.xcframework" + exit 1 +fi + +if [ -f "$SUCCESS_DIR/GhosttyKit.xcframework.tar.gz" ]; then + echo "FAIL: verification helper did not clean up the downloaded archive" + exit 1 +fi + +for expected_arg in --retry --retry-delay --retry-all-errors; do + if ! grep -Fxq -- "$expected_arg" "$SUCCESS_LOG"; then + echo "FAIL: curl invocation missing $expected_arg" + exit 1 + fi +done + +printf '%s %s\n' "$FIXTURE_SHA" "0000000000000000000000000000000000000000000000000000000000000000" > "$CHECKSUMS_FILE" + +if ( + cd "$MISMATCH_DIR" + PATH="$BIN_DIR:$PATH" \ + TEST_CURL_LOG="$MISMATCH_LOG" \ + TEST_FIXTURE_ARCHIVE="$TMP_DIR/GhosttyKit.xcframework.tar.gz" \ + GHOSTTY_SHA="$FIXTURE_SHA" \ + GHOSTTYKIT_CHECKSUMS_FILE="$CHECKSUMS_FILE" \ + "$SCRIPT" +) >"$MISMATCH_OUTPUT" 2>&1; then + echo "FAIL: verification helper succeeded with an invalid pinned checksum" + exit 1 +fi + +if ! grep -Fq "GhosttyKit.xcframework.tar.gz checksum mismatch" "$MISMATCH_OUTPUT"; then + echo "FAIL: verification helper did not report checksum mismatch" + exit 1 +fi + +printf '%s %s\n' "0000000000000000000000000000000000000000" "$ACTUAL_SHA256" > "$CHECKSUMS_FILE" + +if ( + cd "$MISSING_ENTRY_DIR" + PATH="$BIN_DIR:$PATH" \ + TEST_CURL_LOG="$MISMATCH_LOG" \ + TEST_FIXTURE_ARCHIVE="$TMP_DIR/GhosttyKit.xcframework.tar.gz" \ + GHOSTTY_SHA="$FIXTURE_SHA" \ + GHOSTTYKIT_CHECKSUMS_FILE="$CHECKSUMS_FILE" \ + "$SCRIPT" +) >"$MISSING_ENTRY_OUTPUT" 2>&1; then + echo "FAIL: verification helper succeeded without a pinned checksum entry" + exit 1 +fi + +if ! grep -Fq "Missing pinned GhosttyKit checksum for ghostty $FIXTURE_SHA" "$MISSING_ENTRY_OUTPUT"; then + echo "FAIL: verification helper did not report a missing pinned checksum entry" + exit 1 +fi + +echo "PASS: GhosttyKit verification helper enforces pinned checksums" diff --git a/tests/test_ci_scheme_testaction_debug.sh b/tests/test_ci_scheme_testaction_debug.sh new file mode 100755 index 00000000..12347f31 --- /dev/null +++ b/tests/test_ci_scheme_testaction_debug.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCHEME_FILE="GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme" + +if [ ! -f "$SCHEME_FILE" ]; then + echo "FAIL: Missing scheme file at $SCHEME_FILE" >&2 + exit 1 +fi + +if ! grep -q '<TestAction buildConfiguration="Debug"' "$SCHEME_FILE"; then + echo "FAIL: cmux scheme TestAction must use Debug build configuration for UI test setup hooks" >&2 + exit 1 +fi + +echo "PASS: cmux scheme TestAction uses Debug" diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh new file mode 100755 index 00000000..3b4f7f65 --- /dev/null +++ b/tests/test_ci_self_hosted_guard.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Regression test for https://github.com/manaflow-ai/cmux/issues/385. +# Ensures Depot-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 tests in $WORKFLOW_FILE" + echo "Expected line:" + echo " $EXPECTED_IF" + exit 1 +fi + +if ! awk ' + /^ tests-depot:/ { in_tests=1; next } + in_tests && /^ [^[:space:]]/ { in_tests=0 } + in_tests && /runs-on: depot-macos-latest/ { saw_depot=1 } + in_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } + END { exit !(saw_depot && saw_guard) } +' "$WORKFLOW_FILE"; then + echo "FAIL: tests-depot block must keep both depot-macos-latest runner and fork guard" + exit 1 +fi + +echo "PASS: tests-depot Depot runner fork guard is present" diff --git a/tests/test_ci_unit_test_spm_retry.sh b/tests/test_ci_unit_test_spm_retry.sh new file mode 100755 index 00000000..8888a645 --- /dev/null +++ b/tests/test_ci_unit_test_spm_retry.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Regression test for CI unit-test SwiftPM dependency flake handling. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml" + +REQUIRED_PATTERNS=( + "run_unit_tests()" + "Could not resolve package dependencies" + "rm -rf ~/Library/Caches/org.swift.swiftpm" + "OUTPUT=\$(run_unit_tests)" +) + +for pattern in "${REQUIRED_PATTERNS[@]}"; do + if ! grep -Fq "$pattern" "$WORKFLOW_FILE"; then + echo "FAIL: Missing pattern in ci.yml: $pattern" + exit 1 + fi +done + +echo "PASS: CI unit-test SwiftPM retry guard is present" diff --git a/tests/test_ci_universal_release_settings.sh b/tests/test_ci_universal_release_settings.sh new file mode 100644 index 00000000..634a015d --- /dev/null +++ b/tests/test_ci_universal_release_settings.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Regression test for universal GhosttyKit and Release build settings. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +for file in \ + "$ROOT_DIR/.github/workflows/build-ghosttykit.yml" \ + "$ROOT_DIR/scripts/setup.sh" \ + "$ROOT_DIR/scripts/build-sign-upload.sh" +do + if ! grep -Fq -- '-Dxcframework-target=universal' "$file"; then + echo "FAIL: $file must build GhosttyKit with -Dxcframework-target=universal" + exit 1 + fi +done + +if ! awk ' + /\/\* Release \*\// { in_release=1; next } + in_release && /ONLY_ACTIVE_ARCH = YES;/ { saw_yes=1 } + in_release && /ONLY_ACTIVE_ARCH = NO;/ { saw_no=1 } + in_release && /name = Release;/ { in_release=0 } + END { exit !(saw_no && !saw_yes) } +' "$ROOT_DIR/GhosttyTabs.xcodeproj/project.pbxproj"; then + echo "FAIL: Release configurations in project.pbxproj must use ONLY_ACTIVE_ARCH = NO" + exit 1 +fi + +echo "PASS: GhosttyKit builds universal and Release configs disable ONLY_ACTIVE_ARCH" diff --git a/tests/test_claude_hook_missing_socket_error.py b/tests/test_claude_hook_missing_socket_error.py new file mode 100644 index 00000000..d20c7c22 --- /dev/null +++ b/tests/test_claude_hook_missing_socket_error.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Regression test: claude-hook stop surfaces a clear socket-connect error when target socket is missing. +""" + +from __future__ import annotations + +import glob +import os +import shutil +import subprocess +import tempfile + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + candidates: list[str] = [] + candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)] + if candidates: + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + missing_socket = os.path.join(tempfile.gettempdir(), f"cmux-missing-{os.getpid()}.sock") + try: + if os.path.exists(missing_socket): + os.remove(missing_socket) + except OSError: + pass + + env = os.environ.copy() + env["CMUX_CLI_SENTRY_DISABLED"] = "1" + env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + env.pop("CMUX_SOCKET_PATH", None) + + proc = subprocess.run( + [cli_path, "--socket", missing_socket, "claude-hook", "stop"], + input="{}", + text=True, + capture_output=True, + env=env, + check=False, + ) + + if proc.returncode == 0: + print("FAIL: expected non-zero exit when socket is missing") + print(f"stdout={proc.stdout}") + print(f"stderr={proc.stderr}") + return 1 + + expected_prefixes = [ + f"Error: Socket not found at {missing_socket}", + f"Error: Failed to connect to socket at {missing_socket}", + ] + if not any(prefix in proc.stderr for prefix in expected_prefixes): + print("FAIL: missing expected socket error text") + print(f"expected one of: {expected_prefixes!r}") + print(f"stderr: {proc.stderr!r}") + return 1 + + print("PASS: claude-hook stop missing-socket error is explicit") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py new file mode 100644 index 00000000..7763bd76 --- /dev/null +++ b/tests/test_claude_wrapper_hooks.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +""" +Regression tests for Resources/bin/claude wrapper hook injection. +""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import subprocess +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_WRAPPER = ROOT / "Resources" / "bin" / "claude" + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def read_lines(path: Path) -> list[str]: + if not path.exists(): + return [] + return [line.rstrip("\n") for line in path.read_text(encoding="utf-8").splitlines()] + + +def parse_settings_arg(argv: list[str]) -> dict: + if "--settings" not in argv: + return {} + index = argv.index("--settings") + if index + 1 >= len(argv): + return {} + return json.loads(argv[index + 1]) + + +def run_wrapper(*, socket_state: str, argv: list[str]) -> tuple[int, list[str], list[str], str, str]: + with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-test-") as td: + tmp = Path(td) + wrapper_dir = tmp / "wrapper-bin" + real_dir = tmp / "real-bin" + wrapper_dir.mkdir(parents=True, exist_ok=True) + real_dir.mkdir(parents=True, exist_ok=True) + + wrapper = wrapper_dir / "claude" + shutil.copy2(SOURCE_WRAPPER, wrapper) + wrapper.chmod(0o755) + + real_args_log = tmp / "real-args.log" + real_claudecode_log = tmp / "real-claudecode.log" + cmux_log = tmp / "cmux.log" + socket_path = str(tmp / "cmux.sock") + + make_executable( + real_dir / "claude", + """#!/usr/bin/env bash +set -euo pipefail +: > "$FAKE_REAL_ARGS_LOG" +printf '%s\\n' "${CLAUDECODE-__UNSET__}" > "$FAKE_REAL_CLAUDECODE_LOG" +for arg in "$@"; do + printf '%s\\n' "$arg" >> "$FAKE_REAL_ARGS_LOG" +done +""", + ) + + make_executable( + wrapper_dir / "cmux", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s timeout=%s\\n' "$*" "${CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC-__UNSET__}" >> "$FAKE_CMUX_LOG" +if [[ "${1:-}" == "--socket" ]]; then + shift 2 +fi +if [[ "${1:-}" == "ping" ]]; then + if [[ "${FAKE_CMUX_PING_OK:-0}" == "1" ]]; then + exit 0 + fi + exit 1 +fi +exit 0 +""", + ) + + test_socket: socket.socket | None = None + if socket_state in {"live", "stale"}: + test_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + test_socket.bind(socket_path) + + env = os.environ.copy() + env["PATH"] = f"{wrapper_dir}:{real_dir}:/usr/bin:/bin" + env["CMUX_SURFACE_ID"] = "surface:test" + env["CMUX_SOCKET_PATH"] = socket_path + env["FAKE_REAL_ARGS_LOG"] = str(real_args_log) + env["FAKE_REAL_CLAUDECODE_LOG"] = str(real_claudecode_log) + env["FAKE_CMUX_LOG"] = str(cmux_log) + env["FAKE_CMUX_PING_OK"] = "1" if socket_state == "live" else "0" + env["CLAUDECODE"] = "nested-session-sentinel" + + try: + proc = subprocess.run( + ["claude", *argv], + cwd=tmp, + env=env, + capture_output=True, + text=True, + check=False, + ) + finally: + if test_socket is not None: + test_socket.close() + + claudecode_lines = read_lines(real_claudecode_log) + claudecode_value = claudecode_lines[0] if claudecode_lines else "" + return proc.returncode, read_lines(real_args_log), read_lines(cmux_log), proc.stderr.strip(), claudecode_value + + +def expect(condition: bool, message: str, failures: list[str]) -> None: + if not condition: + failures.append(message) + + +def test_live_socket_injects_supported_hooks(failures: list[str]) -> None: + code, real_argv, cmux_log, stderr, claudecode = run_wrapper(socket_state="live", argv=["hello"]) + expect(code == 0, f"live socket: wrapper exited {code}: {stderr}", failures) + expect("--settings" in real_argv, f"live socket: missing --settings in args: {real_argv}", failures) + expect("--session-id" in real_argv, f"live socket: missing --session-id in args: {real_argv}", failures) + expect(real_argv[-1] == "hello", f"live socket: expected original arg to pass through, got {real_argv}", failures) + expect(any(" ping" in line for line in cmux_log), f"live socket: expected cmux ping, got {cmux_log}", failures) + expect( + any("timeout=0.75" in line for line in cmux_log), + f"live socket: expected bounded ping timeout, got {cmux_log}", + failures, + ) + expect(claudecode == "__UNSET__", f"live socket: expected CLAUDECODE unset, got {claudecode!r}", failures) + + settings = parse_settings_arg(real_argv) + hooks = settings.get("hooks", {}) + expect(set(hooks.keys()) == {"SessionStart", "Stop", "Notification"}, f"unexpected hook keys: {hooks.keys()}", failures) + serialized = json.dumps(settings, sort_keys=True) + expect("UserPromptSubmit" not in serialized, "UserPromptSubmit hook should not be injected", failures) + expect("prompt-submit" not in serialized, "prompt-submit subcommand should not be injected", failures) + + +def test_missing_socket_skips_hook_injection(failures: list[str]) -> None: + code, real_argv, cmux_log, stderr, claudecode = run_wrapper(socket_state="missing", argv=["hello"]) + expect(code == 0, f"missing socket: wrapper exited {code}: {stderr}", failures) + expect(real_argv == ["hello"], f"missing socket: expected passthrough args, got {real_argv}", failures) + expect(cmux_log == [], f"missing socket: expected no cmux calls, got {cmux_log}", failures) + expect(claudecode == "__UNSET__", f"missing socket: expected CLAUDECODE unset, got {claudecode!r}", failures) + + +def test_stale_socket_skips_hook_injection(failures: list[str]) -> None: + code, real_argv, cmux_log, stderr, claudecode = run_wrapper(socket_state="stale", argv=["hello"]) + expect(code == 0, f"stale socket: wrapper exited {code}: {stderr}", failures) + expect(real_argv == ["hello"], f"stale socket: expected passthrough args, got {real_argv}", failures) + expect(any(" ping" in line for line in cmux_log), f"stale socket: expected cmux ping probe, got {cmux_log}", failures) + expect( + any("timeout=0.75" in line for line in cmux_log), + f"stale socket: expected bounded ping timeout, got {cmux_log}", + failures, + ) + expect(claudecode == "__UNSET__", f"stale socket: expected CLAUDECODE unset, got {claudecode!r}", failures) + + +def main() -> int: + failures: list[str] = [] + test_live_socket_injects_supported_hooks(failures) + test_missing_socket_skips_hook_injection(failures) + test_stale_socket_skips_hook_injection(failures) + + if failures: + print("FAIL: claude wrapper regression checks failed") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: claude wrapper hooks handle missing/stale sockets and inject only supported hooks") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py new file mode 100644 index 00000000..03e1c7b4 --- /dev/null +++ b/tests/test_cli_claude_teams_env.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams` injects the tmux-style auto-mode env. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def read_text(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8").strip() + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-env-") as td: + tmp = Path(td) + real_bin = tmp / "real-bin" + real_bin.mkdir(parents=True, exist_ok=True) + + env_log = tmp / "agent-teams.log" + tmux_log = tmp / "tmux-path.log" + cmux_bin_log = tmp / "cmux-bin.log" + argv_log = tmp / "argv.log" + tmux_env_log = tmp / "tmux-env.log" + tmux_pane_log = tmp / "tmux-pane.log" + term_log = tmp / "term.log" + term_program_log = tmp / "term-program.log" + socket_path_log = tmp / "socket-path.log" + socket_password_log = tmp / "socket-password.log" + fake_home = tmp / "home" + fake_home.mkdir(parents=True, exist_ok=True) + + make_executable( + real_bin / "claude", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "${CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS-__UNSET__}" > "$FAKE_AGENT_TEAMS_LOG" +command -v tmux > "$FAKE_TMUX_PATH_LOG" +printf '%s\\n' "${CMUX_CLAUDE_TEAMS_CMUX_BIN-__UNSET__}" > "$FAKE_CMUX_BIN_LOG" +printf '%s\\n' "$@" > "$FAKE_ARGV_LOG" +printf '%s\\n' "${TMUX-__UNSET__}" > "$FAKE_TMUX_ENV_LOG" +printf '%s\\n' "${TMUX_PANE-__UNSET__}" > "$FAKE_TMUX_PANE_LOG" +printf '%s\\n' "${TERM-__UNSET__}" > "$FAKE_TERM_LOG" +printf '%s\\n' "${TERM_PROGRAM-__UNSET__}" > "$FAKE_TERM_PROGRAM_LOG" +printf '%s\\n' "${CMUX_SOCKET_PATH-__UNSET__}" > "$FAKE_SOCKET_PATH_LOG" +printf '%s\\n' "${CMUX_SOCKET_PASSWORD-__UNSET__}" > "$FAKE_SOCKET_PASSWORD_LOG" +""", + ) + + env = os.environ.copy() + env["HOME"] = str(fake_home) + env["PATH"] = f"{real_bin}:/usr/bin:/bin" + env["FAKE_AGENT_TEAMS_LOG"] = str(env_log) + env["FAKE_TMUX_PATH_LOG"] = str(tmux_log) + env["FAKE_CMUX_BIN_LOG"] = str(cmux_bin_log) + env["FAKE_ARGV_LOG"] = str(argv_log) + env["FAKE_TMUX_ENV_LOG"] = str(tmux_env_log) + env["FAKE_TMUX_PANE_LOG"] = str(tmux_pane_log) + env["FAKE_TERM_LOG"] = str(term_log) + env["FAKE_TERM_PROGRAM_LOG"] = str(term_program_log) + env["FAKE_SOCKET_PATH_LOG"] = str(socket_path_log) + env["FAKE_SOCKET_PASSWORD_LOG"] = str(socket_password_log) + env["TMUX"] = "__HOST_TMUX__" + env["TMUX_PANE"] = "%999" + env["TERM"] = "xterm-256color" + env["TERM_PROGRAM"] = "__HOST_TERM_PROGRAM__" + explicit_socket_path = str(tmp / "explicit-cmux.sock") + explicit_socket_password = "topsecret" + + proc = subprocess.run( + [ + cli_path, + "--socket", + explicit_socket_path, + "--password", + explicit_socket_password, + "claude-teams", + "--version", + ], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` exited non-zero") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + agent_teams_value = read_text(env_log) + if agent_teams_value != "1": + print(f"FAIL: expected CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, got {agent_teams_value!r}") + return 1 + + tmux_path = read_text(tmux_log) + if not tmux_path: + print("FAIL: fake claude did not observe a tmux binary in PATH") + return 1 + + tmux_name = Path(tmux_path).name + if tmux_name != "tmux": + print(f"FAIL: expected tmux shim path to end with 'tmux', got {tmux_path!r}") + return 1 + + if "claude-teams-bin" not in tmux_path: + print(f"FAIL: expected stable tmux shim path, got {tmux_path!r}") + return 1 + + if tmux_path.startswith(str(real_bin)): + print(f"FAIL: expected cmux tmux shim to shadow PATH, got {tmux_path!r}") + return 1 + + cmux_bin_value = read_text(cmux_bin_log) + if not cmux_bin_value or cmux_bin_value == "__UNSET__": + print("FAIL: missing CMUX_CLAUDE_TEAMS_CMUX_BIN") + return 1 + + if not os.path.exists(cmux_bin_value): + print(f"FAIL: CMUX_CLAUDE_TEAMS_CMUX_BIN does not exist: {cmux_bin_value!r}") + return 1 + + argv_lines = argv_log.read_text(encoding="utf-8").splitlines() + if argv_lines[:2] != ["--teammate-mode", "auto"]: + print(f"FAIL: expected launcher to prepend --teammate-mode auto, got {argv_lines!r}") + return 1 + + if "--version" not in argv_lines: + print(f"FAIL: expected launcher to preserve user args, got {argv_lines!r}") + return 1 + + tmux_env_value = read_text(tmux_env_log) + if tmux_env_value in {"", "__UNSET__"}: + print("FAIL: expected a fake TMUX env value") + return 1 + + tmux_pane_value = read_text(tmux_pane_log) + if tmux_pane_value in {"", "__UNSET__"} or not tmux_pane_value.startswith("%"): + print(f"FAIL: expected a fake TMUX_PANE value, got {tmux_pane_value!r}") + return 1 + + term_value = read_text(term_log) + if term_value != "screen-256color": + print(f"FAIL: expected TERM=screen-256color, got {term_value!r}") + return 1 + + term_program_value = read_text(term_program_log) + if term_program_value != "__UNSET__": + print(f"FAIL: expected TERM_PROGRAM to be unset, got {term_program_value!r}") + return 1 + + socket_path_value = read_text(socket_path_log) + if socket_path_value != explicit_socket_path: + print(f"FAIL: expected CMUX_SOCKET_PATH={explicit_socket_path!r}, got {socket_path_value!r}") + return 1 + + socket_password_value = read_text(socket_password_log) + if socket_password_value != explicit_socket_password: + print( + "FAIL: expected CMUX_SOCKET_PASSWORD to preserve the explicit CLI override, " + f"got {socket_password_value!r}" + ) + return 1 + + print("PASS: cmux claude-teams injects the auto-mode tmux env and shim") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_claude_teams_existing_shim.py b/tests/test_cli_claude_teams_existing_shim.py new file mode 100644 index 00000000..3eadd8e8 --- /dev/null +++ b/tests/test_cli_claude_teams_existing_shim.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams` reuses an existing tmux shim. +""" + +from __future__ import annotations + +import os +import stat +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-shim-") as td: + tmp = Path(td) + home = tmp / "home" + real_bin = tmp / "real-bin" + home.mkdir(parents=True, exist_ok=True) + real_bin.mkdir(parents=True, exist_ok=True) + + shim_dir = home / ".cmuxterm" / "claude-teams-bin" + shim_dir.mkdir(parents=True, exist_ok=True) + shim_path = shim_dir / "tmux" + shim_path.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "exec \"${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}\" __tmux-compat \"$@\"\n", + encoding="utf-8", + ) + shim_path.chmod(0o555) + shim_dir.chmod(0o555) + + make_executable( + real_bin / "claude", + """#!/usr/bin/env bash +set -euo pipefail +printf 'shim=%s\\n' "$(command -v tmux)" +""", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{real_bin}:/usr/bin:/bin" + + proc = subprocess.run( + [cli_path, "claude-teams", "--version"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + shim_dir.chmod(0o755) + shim_path.chmod(0o755) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` failed with an existing shim") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + expected = str(shim_path) + actual = proc.stdout.strip() + if actual != f"shim={expected}": + print(f"FAIL: expected existing shim path {expected!r}, got {actual!r}") + return 1 + + print("PASS: cmux claude-teams reuses an existing tmux shim") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_claude_teams_help_passthrough.py b/tests/test_cli_claude_teams_help_passthrough.py new file mode 100644 index 00000000..73f762f9 --- /dev/null +++ b/tests/test_cli_claude_teams_help_passthrough.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams --help` passes through to Claude. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-help-") as td: + tmp = Path(td) + home = tmp / "home" + real_bin = tmp / "real-bin" + home.mkdir(parents=True, exist_ok=True) + real_bin.mkdir(parents=True, exist_ok=True) + + argv_log = tmp / "argv.log" + + make_executable( + real_bin / "claude", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$@" > "$FAKE_ARGV_LOG" +""", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{real_bin}:/usr/bin:/bin" + env["FAKE_ARGV_LOG"] = str(argv_log) + + proc = subprocess.run( + [cli_path, "claude-teams", "--help"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --help` exited non-zero") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + if not argv_log.exists(): + print("FAIL: launcher intercepted --help instead of invoking Claude") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + argv_lines = argv_log.read_text(encoding="utf-8").splitlines() + if argv_lines[:2] != ["--teammate-mode", "auto"]: + print(f"FAIL: expected launcher to prepend --teammate-mode auto, got {argv_lines!r}") + return 1 + + if "--help" not in argv_lines: + print(f"FAIL: expected --help to reach Claude, got {argv_lines!r}") + return 1 + + print("PASS: cmux claude-teams forwards --help to Claude") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_claude_teams_skips_wrapper_claude.py b/tests/test_cli_claude_teams_skips_wrapper_claude.py new file mode 100644 index 00000000..fb84e3f7 --- /dev/null +++ b/tests/test_cli_claude_teams_skips_wrapper_claude.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams` skips cmux wrapper scripts on PATH. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-wrapper-") as td: + tmp = Path(td) + wrapper_bin = tmp / "wrapper-bin" + real_bin = tmp / "real-bin" + logs = tmp / "logs" + wrapper_bin.mkdir(parents=True, exist_ok=True) + real_bin.mkdir(parents=True, exist_ok=True) + logs.mkdir(parents=True, exist_ok=True) + + real_hit = logs / "real-hit.txt" + + make_executable( + wrapper_bin / "claude", + """#!/usr/bin/env bash +# cmux claude wrapper - injects hooks and session tracking +set -euo pipefail +echo WRAPPER_EXECUTED >&2 +exit 91 +""", + ) + + make_executable( + real_bin / "claude", + f"""#!/usr/bin/env bash +set -euo pipefail +printf 'REAL\\n' > {real_hit} +""", + ) + + env = os.environ.copy() + env["PATH"] = f"{wrapper_bin}:{real_bin}:/usr/bin:/bin" + + proc = subprocess.run( + [cli_path, "claude-teams", "--version"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` executed a wrapper instead of the real claude binary") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + if not real_hit.exists(): + print("FAIL: real claude binary was not reached") + return 1 + + print("PASS: cmux claude-teams skips cmux wrapper scripts on PATH") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_claude_teams_tmux_sequence.py b/tests/test_cli_claude_teams_tmux_sequence.py new file mode 100644 index 00000000..f0df27ba --- /dev/null +++ b/tests/test_cli_claude_teams_tmux_sequence.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams` supports Claude's tmux teammate flow. +""" + +from __future__ import annotations + +import json +import os +import socketserver +import subprocess +import tempfile +import threading +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli +INITIAL_WORKSPACE_ID = "11111111-1111-4111-8111-111111111111" +INITIAL_WINDOW_ID = "22222222-2222-4222-8222-222222222222" +INITIAL_PANE_ID = "33333333-3333-4333-8333-333333333333" +INITIAL_SURFACE_ID = "44444444-4444-4444-8444-444444444444" +INITIAL_TAB_ID = "55555555-5555-4555-8555-555555555555" +NEW_PANE_ID = "66666666-6666-4666-8666-666666666666" +NEW_SURFACE_ID = "77777777-7777-4777-8777-777777777777" + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def read_text(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8").strip() + + +class FakeCmuxState: + def __init__(self) -> None: + self.lock = threading.Lock() + self.requests: list[str] = [] + self.workspace = { + "id": INITIAL_WORKSPACE_ID, + "ref": "workspace:1", + "index": 1, + "title": "demo-team", + } + self.window = { + "id": INITIAL_WINDOW_ID, + "ref": "window:1", + } + self.current_pane_id = INITIAL_PANE_ID + self.current_surface_id = INITIAL_SURFACE_ID + self.panes = [ + { + "id": INITIAL_PANE_ID, + "ref": "pane:1", + "index": 7, + "surface_ids": [INITIAL_SURFACE_ID], + } + ] + self.surfaces = [ + { + "id": INITIAL_SURFACE_ID, + "ref": "surface:1", + "pane_id": INITIAL_PANE_ID, + "title": "leader", + } + ] + + def handle(self, method: str, params: dict[str, object]) -> dict[str, object]: + with self.lock: + self.requests.append(method) + if method == "system.identify": + return { + "socket_path": str(params.get("socket_path", "")), + "focused": { + "workspace_id": self.workspace["id"], + "workspace_ref": self.workspace["ref"], + "window_id": self.window["id"], + "window_ref": self.window["ref"], + "pane_id": self.current_pane_id, + "pane_ref": self._pane_ref(self.current_pane_id), + "surface_id": self.current_surface_id, + "surface_ref": self._surface_ref(self.current_surface_id), + "tab_id": INITIAL_TAB_ID, + "tab_ref": "tab:1", + "surface_type": "terminal", + "is_browser_surface": False, + }, + } + if method == "workspace.current": + return { + "workspace_id": self.workspace["id"], + "workspace_ref": self.workspace["ref"], + } + if method == "workspace.list": + return { + "workspaces": [ + { + "id": self.workspace["id"], + "ref": self.workspace["ref"], + "index": self.workspace["index"], + "title": self.workspace["title"], + } + ] + } + if method == "window.list": + return { + "windows": [ + { + "id": self.window["id"], + "ref": self.window["ref"], + "workspace_id": self.workspace["id"], + "workspace_ref": self.workspace["ref"], + } + ] + } + if method == "pane.list": + return { + "panes": [ + { + "id": pane["id"], + "ref": pane["ref"], + "index": pane["index"], + } + for pane in self.panes + ] + } + if method == "pane.surfaces": + pane_id = str(params.get("pane_id") or "") + pane = self._pane_by_id(pane_id) + return { + "surfaces": [ + { + "id": surface_id, + "selected": surface_id == self.current_surface_id, + } + for surface_id in pane["surface_ids"] + ] + } + if method == "surface.current": + return { + "workspace_id": self.workspace["id"], + "workspace_ref": self.workspace["ref"], + "pane_id": self.current_pane_id, + "pane_ref": self._pane_ref(self.current_pane_id), + "surface_id": self.current_surface_id, + "surface_ref": self._surface_ref(self.current_surface_id), + } + if method == "surface.list": + return { + "surfaces": [ + { + "id": surface["id"], + "ref": surface["ref"], + "title": surface["title"], + "pane_id": surface["pane_id"], + "pane_ref": self._pane_ref(surface["pane_id"]), + } + for surface in self.surfaces + ] + } + if method == "surface.split": + self.panes.append( + { + "id": NEW_PANE_ID, + "ref": "pane:2", + "index": 8, + "surface_ids": [NEW_SURFACE_ID], + } + ) + self.surfaces.append( + { + "id": NEW_SURFACE_ID, + "ref": "surface:2", + "pane_id": NEW_PANE_ID, + "title": "teammate", + } + ) + return { + "surface_id": NEW_SURFACE_ID, + "pane_id": NEW_PANE_ID, + } + if method == "surface.focus": + self.current_surface_id = str(params.get("surface_id") or self.current_surface_id) + surface = self._surface_by_id(self.current_surface_id) + self.current_pane_id = surface["pane_id"] + return {"ok": True} + if method == "pane.resize": + return {"ok": True} + if method == "surface.send_text": + return {"ok": True} + raise RuntimeError(f"Unsupported fake cmux method: {method}") + + def _pane_by_id(self, pane_id: str) -> dict[str, object]: + for pane in self.panes: + if pane["id"] == pane_id or pane["ref"] == pane_id: + return pane + raise RuntimeError(f"Unknown pane id: {pane_id}") + + def _surface_by_id(self, surface_id: str) -> dict[str, object]: + for surface in self.surfaces: + if surface["id"] == surface_id or surface["ref"] == surface_id: + return surface + raise RuntimeError(f"Unknown surface id: {surface_id}") + + def _pane_ref(self, pane_id: str) -> str: + return self._pane_by_id(pane_id)["ref"] # type: ignore[return-value] + + def _surface_ref(self, surface_id: str) -> str: + return self._surface_by_id(surface_id)["ref"] # type: ignore[return-value] + + +class FakeCmuxUnixServer(socketserver.ThreadingUnixStreamServer): + allow_reuse_address = True + + def __init__(self, socket_path: str, state: FakeCmuxState) -> None: + self.state = state + super().__init__(socket_path, FakeCmuxHandler) + + +class FakeCmuxHandler(socketserver.StreamRequestHandler): + def handle(self) -> None: + while True: + line = self.rfile.readline() + if not line: + return + request = json.loads(line.decode("utf-8")) + response = { + "ok": True, + "result": self.server.state.handle( # type: ignore[attr-defined] + request["method"], + request.get("params", {}), + ), + "id": request.get("id"), + } + self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) + self.wfile.flush() + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-seq-") as td: + tmp = Path(td) + home = tmp / "home" + home.mkdir(parents=True, exist_ok=True) + + socket_path = tmp / "fake-cmux.sock" + state = FakeCmuxState() + server = FakeCmuxUnixServer(str(socket_path), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + real_bin = tmp / "real-bin" + real_bin.mkdir(parents=True, exist_ok=True) + + tmux_pane_log = tmp / "tmux-pane.log" + tmux_socket_log = tmp / "tmux-socket.log" + window_target_log = tmp / "window-target.log" + split_pane_log = tmp / "split-pane.log" + pane_list_log = tmp / "pane-list.log" + + make_executable( + real_bin / "claude", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "${TMUX_PANE-__UNSET__}" > "$FAKE_TMUX_PANE_LOG" +printf '%s\\n' "${CMUX_SOCKET_PATH-__UNSET__}" > "$FAKE_SOCKET_LOG" +window_target="$(tmux display-message -t "${TMUX_PANE}" -p '#{session_name}:#{window_index}')" +printf '%s\\n' "$window_target" > "$FAKE_WINDOW_TARGET_LOG" +split_pane="$(tmux split-window -t "${TMUX_PANE}" -h -l 70% -P -F '#{pane_id}')" +printf '%s\\n' "$split_pane" > "$FAKE_SPLIT_PANE_LOG" +tmux select-layout -t "$window_target" main-vertical +tmux resize-pane -t "${TMUX_PANE}" -x 30% +tmux list-panes -t "$window_target" -F '#{pane_id}' > "$FAKE_PANE_LIST_LOG" +""", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{real_bin}:/usr/bin:/bin" + env["CMUX_SOCKET_PATH"] = str(socket_path) + env["FAKE_TMUX_PANE_LOG"] = str(tmux_pane_log) + env["FAKE_SOCKET_LOG"] = str(tmux_socket_log) + env["FAKE_WINDOW_TARGET_LOG"] = str(window_target_log) + env["FAKE_SPLIT_PANE_LOG"] = str(split_pane_log) + env["FAKE_PANE_LIST_LOG"] = str(pane_list_log) + + try: + proc = subprocess.run( + [cli_path, "claude-teams", "--version"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + except subprocess.TimeoutExpired as exc: + print("FAIL: `cmux claude-teams --version` timed out") + print(f"cmd={exc.cmd!r}") + return 1 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` exited non-zero") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + tmux_pane = read_text(tmux_pane_log) + if tmux_pane != f"%{INITIAL_PANE_ID}": + print(f"FAIL: expected TMUX_PANE=%{INITIAL_PANE_ID}, got {tmux_pane!r}") + return 1 + + socket_value = read_text(tmux_socket_log) + if socket_value != str(socket_path): + print(f"FAIL: expected CMUX_SOCKET_PATH={socket_path}, got {socket_value!r}") + return 1 + + window_target = read_text(window_target_log) + if window_target != "cmux:1": + print(f"FAIL: expected tmux window target 'cmux:1', got {window_target!r}") + return 1 + + split_pane = read_text(split_pane_log) + if split_pane != f"%{NEW_PANE_ID}": + print(f"FAIL: expected split-window to print %{NEW_PANE_ID}, got {split_pane!r}") + return 1 + + pane_lines = pane_list_log.read_text(encoding="utf-8").splitlines() + expected_panes = [f"%{INITIAL_PANE_ID}", f"%{NEW_PANE_ID}"] + if pane_lines != expected_panes: + print(f"FAIL: expected list-panes output {expected_panes!r}, got {pane_lines!r}") + return 1 + + if state.current_pane_id != INITIAL_PANE_ID: + print( + "FAIL: expected split-window to keep the leader pane focused, " + f"got current pane {state.current_pane_id!r}" + ) + return 1 + + if "surface.send_text" in state.requests: + print("FAIL: split-window treated '-l 70%' like shell text and called surface.send_text") + print(f"requests={state.requests!r}") + return 1 + + print("PASS: cmux claude-teams supports Claude's tmux teammate flow") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_sigpipe_ignore.py b/tests/test_cli_sigpipe_ignore.py new file mode 100644 index 00000000..16e41810 --- /dev/null +++ b/tests/test_cli_sigpipe_ignore.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Regression test: cmux CLI should not exit with SIGPIPE on broken stdout pipes.""" + +from __future__ import annotations + +import glob +import os +import shutil +import subprocess + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + candidates: list[str] = [] + candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)] + if candidates: + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +def run_with_closed_stdout(cli_path: str, *args: str) -> tuple[int, str]: + read_fd, write_fd = os.pipe() + os.close(read_fd) + proc = subprocess.Popen( + [cli_path, *args], + stdout=write_fd, + stderr=subprocess.PIPE, + text=True, + close_fds=True, + ) + os.close(write_fd) + _, stderr = proc.communicate() + return proc.returncode, (stderr or "").strip() + + +def require_zero_exit(cli_path: str, *args: str) -> tuple[bool, str]: + code, err = run_with_closed_stdout(cli_path, *args) + if code != 0: + cmd = " ".join(args) + return False, f"`cmux {cmd}` exited {code} with closed stdout pipe (stderr={err!r})" + return True, "" + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + ok_version, version_msg = require_zero_exit(cli_path, "--version") + ok_help, help_msg = require_zero_exit(cli_path, "help") + + failures = [msg for msg in [version_msg, help_msg] if msg] + if failures: + print("FAIL: CLI still fails on broken stdout pipes") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: CLI ignores SIGPIPE and exits cleanly when stdout pipe is closed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_socket_autodiscovery.py b/tests/test_cli_socket_autodiscovery.py new file mode 100755 index 00000000..6eaa205d --- /dev/null +++ b/tests/test_cli_socket_autodiscovery.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Regression test: CLI should auto-discover tagged debug sockets from CMUX_TAG.""" + +from __future__ import annotations + +import glob +import os +import shutil +import socket +import subprocess +import threading + + +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.") + + +class PingServer: + def __init__(self, socket_path: str): + self.socket_path = socket_path + self.ready = threading.Event() + self.error: Exception | None = None + self._thread = threading.Thread(target=self._run, daemon=True) + + def start(self) -> None: + self._thread.start() + + def wait_ready(self, timeout: float) -> bool: + return self.ready.wait(timeout) + + def join(self, timeout: float) -> None: + self._thread.join(timeout=timeout) + + def _run(self) -> None: + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + if os.path.exists(self.socket_path): + os.remove(self.socket_path) + server.bind(self.socket_path) + server.listen(1) + server.settimeout(6.0) + self.ready.set() + + # The CLI may probe candidate sockets with a connect-only check before + # issuing the actual command, so handle more than one connection. + for _ in range(4): + conn, _ = server.accept() + with conn: + conn.settimeout(2.0) + data = b"" + while b"\n" not in data: + chunk = conn.recv(4096) + if not chunk: + break + data += chunk + + if b"ping" in data: + conn.sendall(b"PONG\n") + return + raise RuntimeError("Did not receive ping command on test socket") + except Exception as exc: # pragma: no cover - explicit surface on failure + self.error = exc + self.ready.set() + finally: + server.close() + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + tag = f"cli-autodiscover-{os.getpid()}" + socket_path = f"/tmp/cmux-debug-{tag}.sock" + server = PingServer(socket_path) + server.start() + + if not server.wait_ready(2.0): + print("FAIL: socket server did not become ready") + return 1 + + if server.error is not None: + print(f"FAIL: socket server failed to start: {server.error}") + return 1 + + env = os.environ.copy() + env["CMUX_SOCKET_PATH"] = "/tmp/cmux.sock" + env["CMUX_TAG"] = tag + env["CMUX_CLI_SENTRY_DISABLED"] = "1" + env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + try: + proc = subprocess.run( + [cli_path, "ping"], + text=True, + capture_output=True, + env=env, + timeout=8, + check=False, + ) + except Exception as exc: + print(f"FAIL: invoking cmux ping failed: {exc}") + return 1 + finally: + server.join(timeout=2.0) + try: + os.remove(socket_path) + except OSError: + pass + + if server.error is not None: + print(f"FAIL: socket server error: {server.error}") + return 1 + + if proc.returncode != 0: + print("FAIL: cmux ping returned non-zero status") + print(f"stdout={proc.stdout!r}") + print(f"stderr={proc.stderr!r}") + return 1 + + if proc.stdout.strip() != "PONG": + print("FAIL: cmux ping did not use auto-discovered socket") + print(f"stdout={proc.stdout!r}") + print(f"stderr={proc.stderr!r}") + return 1 + + print("PASS: cmux ping auto-discovers tagged socket from CMUX_TAG") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_version_memory_guard.py b/tests/test_cli_version_memory_guard.py new file mode 100644 index 00000000..6252ea5e --- /dev/null +++ b/tests/test_cli_version_memory_guard.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux --version` must not scan huge sibling app lists just to +resolve optional version metadata. +""" + +from __future__ import annotations + +import glob +import os +import plistlib +import shutil +import subprocess +import tempfile +import time + + +JUNK_APP_COUNT = 40000 +RSS_LIMIT_KB = 64 * 1024 +TIMEOUT_SECONDS = 10.0 +EXPECTED_STDOUT = "cmux 9.9.9 (999)" + + +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 copy_runtime_frameworks(cli_path: str, fixture_contents: str) -> None: + frameworks_dir = os.path.join(fixture_contents, "Frameworks") + os.makedirs(frameworks_dir, exist_ok=True) + + search_roots: list[str] = [] + current = os.path.dirname(cli_path) + for _ in range(4): + search_roots.append(os.path.join(current, "Frameworks")) + search_roots.append(os.path.join(current, "PackageFrameworks")) + parent = os.path.dirname(current) + if parent == current: + break + current = parent + + for search_root in search_roots: + sentry_framework = os.path.join(search_root, "Sentry.framework") + if os.path.isdir(sentry_framework): + shutil.copytree(sentry_framework, os.path.join(frameworks_dir, "Sentry.framework")) + return + + +def build_fixture(root: str, cli_path: str) -> str: + app_path = os.path.join(root, "cmux.app") + contents_path = os.path.join(app_path, "Contents") + resources_path = os.path.join(contents_path, "Resources") + bin_path = os.path.join(resources_path, "bin") + os.makedirs(bin_path, exist_ok=True) + + fixture_cli = os.path.join(bin_path, "cmux") + shutil.copy2(cli_path, fixture_cli) + copy_runtime_frameworks(cli_path, contents_path) + + info = { + "CFBundleExecutable": "cmux", + "CFBundleIdentifier": "test.cmux.version-memory-guard", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "9.9.9", + "CFBundleVersion": "999", + } + with open(os.path.join(contents_path, "Info.plist"), "wb") as handle: + plistlib.dump(info, handle) + + # Regular files are enough here because the fallback scan keys off the + # ".app" suffix before it ever tries to inspect bundle contents. + for index in range(JUNK_APP_COUNT): + open(os.path.join(resources_path, f"junk-{index:05d}.app"), "wb").close() + + return fixture_cli + + +def run_with_limits(cli_path: str, *args: str) -> dict[str, object]: + env = dict(os.environ) + env.pop("CMUX_COMMIT", None) + + proc = subprocess.Popen( + [cli_path, *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + + started = time.time() + peak_rss_kb = 0 + failure_reason: str | None = None + + while True: + exit_code = proc.poll() + if exit_code is not None: + stdout, stderr = proc.communicate() + return { + "exit_code": exit_code, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": time.time() - started, + "peak_rss_kb": peak_rss_kb, + "failure_reason": None, + } + + try: + rss_kb = int( + subprocess.check_output( + ["ps", "-o", "rss=", "-p", str(proc.pid)], + text=True, + ).strip() + or "0" + ) + except subprocess.CalledProcessError: + rss_kb = 0 + + peak_rss_kb = max(peak_rss_kb, rss_kb) + elapsed = time.time() - started + + if rss_kb > RSS_LIMIT_KB: + failure_reason = f"rss limit exceeded ({rss_kb} KB > {RSS_LIMIT_KB} KB)" + elif elapsed > TIMEOUT_SECONDS: + failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)" + + if failure_reason: + proc.kill() + stdout, stderr = proc.communicate() + return { + "exit_code": proc.returncode, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": elapsed, + "peak_rss_kb": peak_rss_kb, + "failure_reason": failure_reason, + } + + time.sleep(0.05) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-version-memory-guard-") as root: + fixture_cli = build_fixture(root, cli_path) + result = run_with_limits(fixture_cli, "--version") + + if result["failure_reason"]: + print("FAIL: `cmux --version` exceeded runtime guard") + print(f"reason={result['failure_reason']}") + print(f"elapsed={result['elapsed']:.2f}s") + print(f"peak_rss_kb={result['peak_rss_kb']}") + print(f"stdout={result['stdout']}") + print(f"stderr={result['stderr']}") + return 1 + + if result["exit_code"] != 0: + print("FAIL: `cmux --version` exited non-zero") + print(f"exit={result['exit_code']}") + print(f"stdout={result['stdout']}") + print(f"stderr={result['stderr']}") + return 1 + + if result["stdout"] != EXPECTED_STDOUT: + print("FAIL: unexpected version output") + print(f"stdout={result['stdout']!r}") + print(f"expected={EXPECTED_STDOUT!r}") + return 1 + + print( + "PASS: `cmux --version` exits within memory/time limits " + f"(peak_rss_kb={result['peak_rss_kb']}, elapsed={result['elapsed']:.2f}s)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cmd_option_t_close_other_tabs_in_pane.py b/tests/test_cmd_option_t_close_other_tabs_in_pane.py new file mode 100644 index 00000000..778900a2 --- /dev/null +++ b/tests/test_cmd_option_t_close_other_tabs_in_pane.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Regression test: Cmd+Option+T closes all other tabs in the focused pane +after an explicit confirmation. + +Run this against an app launched with CMUX_SOCKET_MODE=allowAll. +""" + +import os +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05) -> bool: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return True + time.sleep(interval_s) + return False + + +def _pane_state(client: cmux) -> list[dict]: + rows: list[dict] = [] + for index, panel_id, title, selected in client.list_pane_surfaces(): + rows.append( + { + "index": index, + "panel_id": panel_id, + "title": title, + "selected": selected, + } + ) + return rows + + +def _send_shortcut_via_system_events(key: str, modifiers: str) -> None: + script = f'tell application "System Events" to keystroke "{key}" using {{{modifiers}}}' + try: + subprocess.run(["osascript", "-e", script], check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + raise cmuxError( + "Failed to send keyboard shortcut via System Events. " + f"Ensure macOS Accessibility automation is enabled. stderr={stderr}" + ) from exc + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + if not client.ping(): + raise cmuxError( + f"Socket ping failed on {SOCKET_PATH}. " + "Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test." + ) + + workspace_id = client.new_workspace() + try: + client.select_workspace(workspace_id) + time.sleep(0.25) + client.activate_app() + time.sleep(0.15) + + # Create two additional tabs in the current focused pane. + client.new_surface() + client.new_surface() + time.sleep(0.25) + + before = _pane_state(client) + if len(before) < 3: + raise cmuxError(f"Expected >=3 tabs before shortcut, got {before}") + + selected_rows = [row for row in before if row["selected"]] + if len(selected_rows) != 1: + raise cmuxError(f"Expected exactly one selected tab before shortcut, got {before}") + selected_panel_id = selected_rows[0]["panel_id"] + + expected_to_close = [row for row in before if row["panel_id"] != selected_panel_id] + if len(expected_to_close) < 2: + raise cmuxError( + f"Expected at least two non-selected tabs before shortcut, got {before}" + ) + + # Trigger shortcut via real OS key event; this should open the confirmation dialog. + _send_shortcut_via_system_events("t", "command down, option down") + time.sleep(0.25) + after_trigger = _pane_state(client) + if len(after_trigger) != len(before): + raise cmuxError( + "Cmd+Option+T should require confirmation before closing.\n" + f"before={before}\n" + f"after_trigger={after_trigger}" + ) + + # Confirm the dialog with Cmd+D (wired to click the destructive "Close" button). + _send_shortcut_via_system_events("d", "command down") + closed = _wait_until(lambda: len(_pane_state(client)) == 1, timeout_s=5.0, interval_s=0.05) + if not closed: + raise cmuxError( + "Timed out waiting for tabs to close after confirming Cmd+Option+T.\n" + f"before={before}\n" + f"after_trigger={after_trigger}\n" + f"after_confirm={_pane_state(client)}" + ) + + after_confirm = _pane_state(client) + if len(after_confirm) != 1: + raise cmuxError( + f"Expected one remaining tab after confirmation, got {after_confirm}" + ) + remaining = after_confirm[0] + if remaining["panel_id"] != selected_panel_id: + raise cmuxError( + "Expected selected tab to remain after closing others.\n" + f"expected_selected={selected_panel_id}\n" + f"remaining={remaining}\n" + f"before={before}" + ) + + print("PASS: Cmd+Option+T closed all other tabs in focused pane.") + print(f"workspace={workspace_id}") + print(f"selected_panel={selected_panel_id}") + return 0 + finally: + try: + client.close_workspace(workspace_id) + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ctrl_enter_keybind.py b/tests/test_ctrl_enter_keybind.py deleted file mode 100644 index 29c305f2..00000000 --- a/tests/test_ctrl_enter_keybind.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -""" -Automated test for ctrl+enter keybind using real keystrokes. - -Requires: - - cmux running - - Accessibility permissions for System Events (osascript) - - keybind = ctrl+enter=text:\\r (or \\n/\\x0d) configured in Ghostty config -""" - -import os -import sys -import time -import subprocess -from pathlib import Path -from typing import Optional - -# Add the directory containing cmux.py to the path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from cmux import cmux, cmuxError - - -def run_osascript(script: str) -> subprocess.CompletedProcess[str]: - # Use capture_output so we can detect common permission failures and skip. - result = subprocess.run( - ["osascript", "-e", script], - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise subprocess.CalledProcessError( - result.returncode, - result.args, - output=result.stdout, - stderr=result.stderr, - ) - return result - - -def is_keystroke_permission_error(err: subprocess.CalledProcessError) -> bool: - text = f"{getattr(err, 'stderr', '') or ''}\n{getattr(err, 'output', '') or ''}" - return "not allowed to send keystrokes" in text or "(1002)" in text - - -def has_ctrl_enter_keybind(config_text: str) -> bool: - for line in config_text.splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - if "ctrl+enter" in stripped and "text:" in stripped: - if "\\r" in stripped or "\\n" in stripped or "\\x0d" in stripped: - return True - return False - - -def find_config_with_keybind() -> Optional[Path]: - home = Path.home() - candidates = [ - home / "Library/Application Support/com.mitchellh.ghostty/config.ghostty", - home / "Library/Application Support/com.mitchellh.ghostty/config", - home / ".config/ghostty/config.ghostty", - home / ".config/ghostty/config", - ] - for path in candidates: - if not path.exists(): - continue - try: - if has_ctrl_enter_keybind(path.read_text(encoding="utf-8")): - return path - except OSError: - continue - return None - - -def test_ctrl_enter_keybind(client: cmux) -> tuple[bool, str]: - marker = Path("/tmp") / f"ghostty_ctrl_enter_{os.getpid()}" - marker.unlink(missing_ok=True) - - # Create a fresh tab to avoid interfering with existing sessions - new_tab_id = client.new_tab() - client.select_tab(new_tab_id) - time.sleep(0.3) - try: - # Make sure the app is focused for keystrokes - bundle_id = cmux.default_bundle_id() - run_osascript(f'tell application id "{bundle_id}" to activate') - time.sleep(0.2) - - # Clear any running command - try: - client.send_key("ctrl-c") - time.sleep(0.2) - except Exception: - pass - - # Type the command (without pressing Enter) - run_osascript(f'tell application "System Events" to keystroke "touch {marker}"') - time.sleep(0.1) - - # Send Ctrl+Enter (key code 36 = Return) - run_osascript('tell application "System Events" to key code 36 using control down') - time.sleep(0.5) - - ok = marker.exists() - return ok, ("Ctrl+Enter keybind executed command" if ok else "Marker not created by Ctrl+Enter") - finally: - if marker.exists(): - marker.unlink(missing_ok=True) - try: - client.close_tab(new_tab_id) - except Exception: - pass - - -def run_tests() -> int: - print("=" * 60) - print("cmux Ctrl+Enter Keybind Test") - print("=" * 60) - print() - - socket_path = cmux.default_socket_path() - if not os.path.exists(socket_path): - print(f"SKIP: Socket not found at {socket_path}") - print("Tip: start cmux first (or set CMUX_TAG / CMUX_SOCKET_PATH).") - return 0 - - config_path = find_config_with_keybind() - if not config_path: - print("SKIP: Required keybind not found in Ghostty config.") - print("Expected a line like: keybind = ctrl+enter=text:\\r") - return 0 - - print(f"Using keybind from: {config_path}") - print() - - try: - with cmux() as client: - ok, message = test_ctrl_enter_keybind(client) - status = "✅" if ok else "❌" - print(f"{status} {message}") - return 0 if ok else 1 - except cmuxError as e: - print(f"SKIP: {e}") - return 0 - except subprocess.CalledProcessError as e: - if is_keystroke_permission_error(e): - print("SKIP: osascript/System Events not allowed to send keystrokes (Accessibility permission missing)") - return 0 - print(f"Error: osascript failed: {e}") - if getattr(e, "stderr", None): - print(e.stderr.strip()) - if getattr(e, "output", None): - print(e.output.strip()) - return 1 - - -if __name__ == "__main__": - sys.exit(run_tests()) diff --git a/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py b/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py new file mode 100644 index 00000000..32ff0d64 --- /dev/null +++ b/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Regression: zsh prompt redraws should not replay fresh-line OSC 133;A markers. + +Prompt themes with async redraws (such as Prezto-like setups) can call +`zle reset-prompt` after the prompt is already visible. Ghostty's zsh shell +integration should emit a single fresh prompt mark for the actual prompt, then +use OSC 133;P for redraws so redraws stay in place instead of looking like +extra prompt lines. +""" + +from __future__ import annotations + +import os +import pty +import select +import shutil +import subprocess +import tempfile +import time +from pathlib import Path + + +FRESH_PROMPT = b"\x1b]133;A;cl=line\x07" +PROMPT_START = b"\x1b]133;P;k=i\x07" +END_COMMAND = b"\x1b]133;D\x07" +START_OUTPUT = b"\x1b]133;C\x07" + + +def _write_redrawing_zshrc(path: Path) -> None: + path.write_text( + """ +autoload -Uz add-zsh-hook + +setopt prompt_cr prompt_percent prompt_sp prompt_subst +PROMPT='%F{4}%1~%f %# ' +RPROMPT='' + +typeset -gi _cmux_redraw_done=0 +typeset -g _cmux_redraw_fd='' + +_cmux_redraw_precmd() { + _cmux_redraw_done=0 +} + +_cmux_redraw_ready() { + emulate -L zsh + local fd="${1:-$_cmux_redraw_fd}" + if [[ -n "$fd" ]]; then + zle -F "$fd" + exec {fd}<&- + fi + _cmux_redraw_fd='' + (( _cmux_redraw_done )) && return 0 + _cmux_redraw_done=1 + zle reset-prompt +} + +_cmux_redraw_line_init() { + if (( !_cmux_redraw_done )) && [[ -z "$_cmux_redraw_fd" ]]; then + exec {_cmux_redraw_fd}< <( + sleep 0.05 + printf 'ready\\n' + ) + zle -F "$_cmux_redraw_fd" _cmux_redraw_ready + fi +} + +add-zsh-hook precmd _cmux_redraw_precmd +zle -N zle-line-init _cmux_redraw_line_init +""".lstrip(), + encoding="utf-8", + ) + + +def _capture_session(env: dict[str, str], zsh_path: str) -> bytes: + master, slave = pty.openpty() + proc = subprocess.Popen( + [zsh_path, "-d", "-i"], + stdin=slave, + stdout=slave, + stderr=slave, + env=env, + close_fds=True, + ) + os.close(slave) + + output = bytearray() + start = time.time() + phase = 0 + try: + while time.time() - start < 5: + readable, _, _ = select.select([master], [], [], 0.2) + if master in readable: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + output.extend(chunk) + + elapsed = time.time() - start + if phase == 0 and elapsed > 1.0: + os.write(master, b"\n") + phase = 1 + elif phase == 1 and elapsed > 2.5: + os.write(master, b"exit\n") + phase = 2 + finally: + try: + proc.wait(timeout=5) + finally: + os.close(master) + + return bytes(output) + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + wrapper_dir = root / "ghostty" / "src" / "shell-integration" / "zsh" + if not (wrapper_dir / ".zshenv").exists(): + print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}") + return 0 + + zsh_path = shutil.which("zsh") + if zsh_path is None: + print("SKIP: zsh not installed") + return 0 + + base = Path(tempfile.mkdtemp(prefix="cmux_ghostty_prompt_redraw_")) + try: + home = base / "home" + home.mkdir(parents=True, exist_ok=True) + _write_redrawing_zshrc(home / ".zshrc") + + env = dict(os.environ) + env["HOME"] = str(home) + env["ZDOTDIR"] = str(wrapper_dir) + env["GHOSTTY_ZSH_ZDOTDIR"] = str(home) + env["GHOSTTY_RESOURCES_DIR"] = str(root / "ghostty" / "src") + env.pop("GHOSTTY_SHELL_FEATURES", None) + env.pop("GHOSTTY_BIN_DIR", None) + + output = _capture_session(env, zsh_path) + + marker = output.find(END_COMMAND) + if marker == -1: + print("FAIL: did not observe OSC 133;D for the empty command prompt cycle") + return 1 + + end = output.find(START_OUTPUT, marker + len(END_COMMAND)) + if end == -1: + end = len(output) + + prompt_cycle = output[marker:end] + fresh_count = prompt_cycle.count(FRESH_PROMPT) + prompt_start_count = prompt_cycle.count(PROMPT_START) + + if fresh_count != 1: + print(f"FAIL: expected exactly 1 fresh prompt marker after redraw, saw {fresh_count}") + return 1 + + if prompt_start_count < 1: + print("FAIL: expected redraw path to emit OSC 133;P prompt-start markers") + return 1 + + print("PASS: zsh prompt redraws keep a single fresh prompt marker and reuse OSC 133;P") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ghostty_zsh_pure_preprompt_redraw.py b/tests/test_ghostty_zsh_pure_preprompt_redraw.py new file mode 100644 index 00000000..ab916fe5 --- /dev/null +++ b/tests/test_ghostty_zsh_pure_preprompt_redraw.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +Regression: Ghostty's zsh integration must not leave stale Pure-style preprompt +lines behind after an async redraw. + +Pure does not render its top path/git line as a static multiline PS1. Instead, +it rewrites PROMPT with a special newline sequence and later calls +`zle .reset-prompt` when async git info arrives. Plain zsh redraws that cleanly. +The Ghostty integration currently leaves stale copies of the old top line behind. + +This test uses a minimal Pure-like prompt implementation as a control: +- plain zsh must redraw without stale preprompt lines +- Ghostty-integrated zsh must match that behavior +""" + +from __future__ import annotations + +import os +import pty +import re +import select +import shutil +import subprocess +import tempfile +import time +from pathlib import Path + + +_MINIMAL_PURE_ZSHRC = r""" +setopt promptsubst nopromptcr nopromptsp +prompt_newline=$'\n%{\r%}' + +typeset -g CMUX_TOP='%F{4}%~%f' +typeset -g CMUX_LAST_PROMPT='' +typeset -gi CMUX_ASYNC_DONE=0 +typeset -g CMUX_ASYNC_FD='' + +cmux_render_prompt() { + local cleaned_ps1=$PROMPT + if [[ $PROMPT = *$prompt_newline* ]]; then + cleaned_ps1=${PROMPT##*${prompt_newline}} + fi + + PROMPT="${CMUX_TOP}${prompt_newline}${cleaned_ps1:-%F{5}❯%f }" + + local expanded_prompt="${(S%%)PROMPT}" + if [[ ${1:-} == precmd ]]; then + print + elif [[ $CMUX_LAST_PROMPT != $expanded_prompt ]]; then + zle && zle .reset-prompt + fi + typeset -g CMUX_LAST_PROMPT=$expanded_prompt +} + +cmux_async_ready() { + emulate -L zsh + local fd="${1:-$CMUX_ASYNC_FD}" + if [[ -n $fd ]]; then + zle -F "$fd" + exec {fd}<&- + fi + CMUX_ASYNC_FD='' + + (( CMUX_ASYNC_DONE )) && return + CMUX_ASYNC_DONE=1 + CMUX_TOP='%F{4}%~%f %F{242}main%f%F{218}*%f %F{6}⇣⇡%f' + cmux_render_prompt async +} + +precmd() { + CMUX_ASYNC_DONE=0 + cmux_render_prompt precmd +} + +cmux_line_init() { + if (( !CMUX_ASYNC_DONE )) && [[ -z $CMUX_ASYNC_FD ]]; then + exec {CMUX_ASYNC_FD}< <( + sleep 0.05 + printf 'ready\n' + ) + zle -F "$CMUX_ASYNC_FD" cmux_async_ready + fi +} + +zle -N zle-line-init cmux_line_init +PROMPT='%F{5}❯%f ' +""".lstrip() + +_ANSI_RE = re.compile(rb"\x1b\][^\x07]*\x07|\x1b\[[0-9;?]*[ -/]*[@-~]|\r") + + +def _capture_session( + *, + use_ghostty: bool, + wrapper_dir: Path, + resources_dir: Path, + workdir: Path, + zsh_path: str, +) -> str: + base = Path(tempfile.mkdtemp(prefix="cmux_ghostty_pure_preprompt_")) + try: + home = base / "home" + home.mkdir(parents=True, exist_ok=True) + (home / ".zshrc").write_text(_MINIMAL_PURE_ZSHRC, encoding="utf-8") + + env = dict(os.environ) + env["HOME"] = str(home) + env["TERM"] = "xterm-256color" + env.pop("GHOSTTY_SHELL_FEATURES", None) + env.pop("GHOSTTY_BIN_DIR", None) + if use_ghostty: + env["ZDOTDIR"] = str(wrapper_dir) + env["GHOSTTY_ZSH_ZDOTDIR"] = str(home) + env["GHOSTTY_RESOURCES_DIR"] = str(resources_dir) + else: + env["ZDOTDIR"] = str(home) + env.pop("GHOSTTY_ZSH_ZDOTDIR", None) + env.pop("GHOSTTY_RESOURCES_DIR", None) + + master, slave = pty.openpty() + proc = subprocess.Popen( + [zsh_path, "-d", "-i"], + cwd=str(workdir), + stdin=slave, + stdout=slave, + stderr=slave, + env=env, + close_fds=True, + ) + os.close(slave) + + output = bytearray() + start = time.time() + phase = 0 + try: + while time.time() - start < 4.5: + readable, _, _ = select.select([master], [], [], 0.2) + if master in readable: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + output.extend(chunk) + + elapsed = time.time() - start + if phase == 0 and elapsed > 1.2: + os.write(master, b"\n") + phase = 1 + elif phase == 1 and elapsed > 2.8: + os.write(master, b"exit\n") + phase = 2 + finally: + try: + proc.wait(timeout=5) + finally: + os.close(master) + + cleaned = _ANSI_RE.sub(b"", bytes(output)).decode("utf-8", errors="replace") + return cleaned + finally: + shutil.rmtree(base, ignore_errors=True) + + +def _stale_preprompt_lines(cleaned: str, path_line: str, async_line: str) -> tuple[int, int]: + marker = cleaned.find(async_line) + if marker == -1: + return (-1, -1) + + tail = cleaned[marker + len(async_line) :] + return (tail.count(path_line), tail.count(async_line)) + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + wrapper_dir = root / "ghostty" / "src" / "shell-integration" / "zsh" + resources_dir = root / "ghostty" / "src" + workdir = root + + if not (wrapper_dir / ".zshenv").exists(): + print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}") + return 0 + zsh_path = shutil.which("zsh") + if zsh_path is None: + print("SKIP: zsh not installed") + return 0 + + path_line = f"{workdir}\n" + async_line = f"{workdir} main* ⇣⇡" + + plain = _capture_session( + use_ghostty=False, + wrapper_dir=wrapper_dir, + resources_dir=resources_dir, + workdir=workdir, + zsh_path=zsh_path, + ) + ghostty = _capture_session( + use_ghostty=True, + wrapper_dir=wrapper_dir, + resources_dir=resources_dir, + workdir=workdir, + zsh_path=zsh_path, + ) + + plain_stale, plain_async = _stale_preprompt_lines(plain, path_line, async_line) + ghostty_stale, ghostty_async = _stale_preprompt_lines(ghostty, path_line, async_line) + + if plain_stale < 0: + print("FAIL: plain zsh control never rendered the async preprompt line") + return 1 + if ghostty_stale < 0: + print("FAIL: Ghostty zsh integration never rendered the async preprompt line") + return 1 + + if plain_stale != 0: + print(f"FAIL: plain zsh control left stale preprompt lines behind ({plain_stale})") + return 1 + + if ghostty_stale != plain_stale: + print( + "FAIL: Ghostty zsh integration left stale preprompt lines behind " + f"(ghostty={ghostty_stale}, plain={plain_stale}, async_renders={ghostty_async})" + ) + return 1 + + print("PASS: Ghostty zsh integration redraws Pure-style preprompts without stale lines") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py new file mode 100644 index 00000000..973e98dd --- /dev/null +++ b/tests/test_issue_1138_sidebar_pr_polling.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +""" +Regression coverage for issue #1138. + +Validates that shell integration: +1) keeps polling PR state while idle and recovers after a transient gh failure +2) resolves the current branch PR via `gh pr view` instead of repository-wide + branch-name matching +3) clears stale PR state when the branch changes and the new probe fails +4) recovers when a gh probe wedges longer than the async timeout +5) keeps polling in bash after prompt-render helper commands run +6) tears down the timed-out gh probe instead of leaking it in the background +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import textwrap +from pathlib import Path + + +class BoundUnixSocket: + def __init__(self, path: Path) -> None: + self.path = path + self.sock: socket.socket | None = None + + def __enter__(self) -> "BoundUnixSocket": + self.path.parent.mkdir(parents=True, exist_ok=True) + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.bind(str(self.path)) + self.sock.listen(1) + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self.sock is not None: + self.sock.close() + try: + self.path.unlink() + except FileNotFoundError: + pass + + +def _write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def _git_stub() -> str: + return textwrap.dedent( + """\ + #!/bin/sh + repo_path="$PWD" + if [ "$1" = "-C" ]; then + repo_path="$2" + shift + shift + fi + + head_file="$repo_path/.git/HEAD" + branch="" + if [ -f "$head_file" ]; then + head_line="$(cat "$head_file")" + case "$head_line" in + ref:\ refs/heads/*) + branch="${head_line#ref: refs/heads/}" + ;; + esac + fi + + if [ "$1" = "branch" ] && [ "$2" = "--show-current" ]; then + if [ -n "$branch" ]; then + printf '%s\\n' "$branch" + fi + exit 0 + fi + + if [ "$1" = "status" ] && [ "$2" = "--porcelain" ] && [ "$3" = "-uno" ]; then + exit 0 + fi + + printf 'unexpected git args: %s\\n' "$*" >&2 + exit 1 + """ + ) + + +def _gh_stub() -> str: + return textwrap.dedent( + """\ + #!/bin/sh + args_log="${CMUX_TEST_GH_ARGS_LOG:?}" + count_file="${CMUX_TEST_GH_COUNT_FILE:?}" + pid_file="${CMUX_TEST_GH_PID_FILE:-}" + scenario="${CMUX_TEST_SCENARIO:?}" + head_file="${CMUX_TEST_HEAD_FILE:?}" + + printf '%s\\n' "$*" >> "$args_log" + + count=0 + if [ -f "$count_file" ]; then + count="$(cat "$count_file")" + fi + count=$((count + 1)) + printf '%s\\n' "$count" > "$count_file" + + if [ "$1" != "pr" ] || [ "$2" != "view" ]; then + printf 'unexpected gh args: %s\\n' "$*" >&2 + exit 9 + fi + + branch="" + if [ -f "$head_file" ]; then + head_line="$(cat "$head_file")" + case "$head_line" in + ref:\ refs/heads/*) + branch="${head_line#ref: refs/heads/}" + ;; + esac + fi + + case "$scenario" in + prompt_helper_idle) + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; + transient_same_context) + if [ "$count" -eq 1 ]; then + printf 'rate limit exceeded\\n' >&2 + exit 1 + fi + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; + branch_switch_clear) + if [ "$branch" = "feature/old" ]; then + printf '111\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/111\\n' + exit 0 + fi + if [ "$branch" = "feature/new" ]; then + printf 'network unavailable\\n' >&2 + exit 1 + fi + printf 'no pull requests found for branch "%s"\\n' "$branch" >&2 + exit 1 + ;; + timeout_recovery) + if [ "$count" -eq 1 ]; then + if [ -n "$pid_file" ]; then + printf '%s\\n' "$$" > "$pid_file" + fi + sleep "${CMUX_TEST_HANG_SECONDS:-4}" + exit 0 + fi + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; + *) + printf 'unknown scenario: %s\\n' "$scenario" >&2 + exit 2 + ;; + esac + """ + ) + + +def _shell_command(kind: str, scenario: str) -> str: + shared = { + "prompt_helper_idle": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=1\n' + '_cmux_prompt_entry\n' + ': "$(/bin/printf helper)"\n' + 'sleep 3\n' + '_cmux_cleanup\n' + ), + "transient_same_context": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=1\n' + '_cmux_prompt_entry\n' + 'sleep 3\n' + '_cmux_cleanup\n' + ), + "branch_switch_clear": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_prompt_entry\n' + 'sleep 1\n' + 'printf \'ref: refs/heads/feature/new\\n\' > "$CMUX_TEST_HEAD_FILE"\n' + '_cmux_prompt_entry\n' + 'sleep 2\n' + '_cmux_cleanup\n' + ), + "timeout_recovery": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=1\n' + '_CMUX_ASYNC_JOB_TIMEOUT=1\n' + '_cmux_prompt_entry\n' + 'sleep 4\n' + '_cmux_cleanup\n' + ), + }[scenario] + + if kind == "zsh": + return textwrap.dedent( + f"""\ + source "$CMUX_TEST_SCRIPT" + _cmux_send() {{ print -r -- "$1" >> "$CMUX_TEST_SEND_LOG"; }} + _cmux_prompt_entry() {{ _cmux_precmd; }} + _cmux_cleanup() {{ _cmux_zshexit; }} + {shared}""" + ) + + if kind == "bash": + return textwrap.dedent( + f"""\ + source "$CMUX_TEST_SCRIPT" + _cmux_send() {{ printf '%s\\n' "$1" >> "$CMUX_TEST_SEND_LOG"; }} + _cmux_prompt_entry() {{ _cmux_prompt_command; }} + _cmux_cleanup() {{ type _cmux_bash_cleanup >/dev/null 2>&1 && _cmux_bash_cleanup; }} + {shared}""" + ) + + raise ValueError(f"Unsupported shell kind: {kind}") + + +def _read_lines(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 _report_line(number: int) -> str: + return ( + f"report_pr {number} https://github.com/manaflow-ai/cmux/pull/{number} " + "--state=open --tab=00000000-0000-0000-0000-000000000001 " + "--panel=00000000-0000-0000-0000-000000000002" + ) + + +def _pid_exists(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, scenario: str) -> tuple[int, str]: + bindir = base / "bin" + repo = base / "repo" + repo_git = repo / ".git" + socket_path = base / "cmux.sock" + send_log = base / f"{shell}-{scenario}-send.log" + gh_count_file = base / f"{shell}-{scenario}-gh-count.txt" + gh_args_log = base / f"{shell}-{scenario}-gh-args.log" + gh_pid_file = base / f"{shell}-{scenario}-gh-pid.txt" + head_file = repo_git / "HEAD" + + bindir.mkdir(parents=True, exist_ok=True) + repo_git.mkdir(parents=True, exist_ok=True) + initial_branch = "feature/old" if scenario == "branch_switch_clear" else "feature/issue-1138" + head_file.write_text(f"ref: refs/heads/{initial_branch}\n", encoding="utf-8") + _write_executable(bindir / "git", _git_stub()) + _write_executable(bindir / "gh", _gh_stub()) + + env = dict(os.environ) + env["PATH"] = f"{bindir}:{env.get('PATH', '')}" + env["CMUX_SOCKET_PATH"] = str(socket_path) + env["CMUX_TAB_ID"] = "00000000-0000-0000-0000-000000000001" + env["CMUX_PANEL_ID"] = "00000000-0000-0000-0000-000000000002" + env["CMUX_TEST_SCRIPT"] = str(script) + env["CMUX_TEST_REPO"] = str(repo) + env["CMUX_TEST_SEND_LOG"] = str(send_log) + env["CMUX_TEST_GH_COUNT_FILE"] = str(gh_count_file) + env["CMUX_TEST_GH_ARGS_LOG"] = str(gh_args_log) + env["CMUX_TEST_GH_PID_FILE"] = str(gh_pid_file) + env["CMUX_TEST_SCENARIO"] = scenario + env["CMUX_TEST_HEAD_FILE"] = str(head_file) + env["CMUX_TEST_HANG_SECONDS"] = "4" + + with BoundUnixSocket(socket_path): + result = subprocess.run( + [shell, *shell_args, _shell_command(shell, scenario)], + env=env, + capture_output=True, + text=True, + timeout=12, + ) + + combined_output = (result.stdout or "") + (result.stderr or "") + if result.returncode != 0: + return (result.returncode, combined_output) + + send_lines = _read_lines(send_log) + gh_args_lines = _read_lines(gh_args_log) + gh_count = int((gh_count_file.read_text(encoding="utf-8").strip() or "0")) if gh_count_file.exists() else 0 + + if not gh_args_lines: + return (1, f"{shell}/{scenario}: expected at least one gh invocation") + if any(not line.startswith("pr view ") for line in gh_args_lines): + return (1, f"{shell}/{scenario}: expected gh pr view only\n" + "\n".join(gh_args_lines)) + + if scenario == "prompt_helper_idle": + if gh_count < 2: + return (1, f"{shell}/{scenario}: expected idle polling to survive prompt helpers, saw {gh_count}") + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "transient_same_context": + if gh_count < 2: + return (1, f"{shell}/{scenario}: expected at least 2 gh probes while idle, saw {gh_count}") + if any(line.startswith("clear_pr ") for line in send_lines): + return (1, f"{shell}/{scenario}: transient failure should not clear PR state\n" + "\n".join(send_lines)) + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: expected recovered report_pr payload\n" + "\n".join(send_lines)) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "branch_switch_clear": + old_report = _report_line(111) + if old_report not in send_lines: + return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines)) + try: + old_index = send_lines.index(old_report) + except ValueError: + return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines)) + clear_indices = [idx for idx, line in enumerate(send_lines) if line.startswith("clear_pr ")] + if not clear_indices: + return (1, f"{shell}/{scenario}: expected clear_pr after branch change\n" + "\n".join(send_lines)) + if clear_indices[0] <= old_index: + return (1, f"{shell}/{scenario}: clear_pr happened before old report\n" + "\n".join(send_lines)) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "timeout_recovery": + if gh_count < 2: + return (1, f"{shell}/{scenario}: expected timed-out probe to be retried, saw {gh_count}") + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr after timeout recovery\n" + "\n".join(send_lines)) + if gh_pid_file.exists(): + gh_pid = int(gh_pid_file.read_text(encoding="utf-8").strip() or "0") + if gh_pid > 0 and _pid_exists(gh_pid): + return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}") + return (0, f"{shell}/{scenario}: ok") + + return (1, f"{shell}/{scenario}: unhandled scenario") + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + cases = [ + ("zsh", ["-f", "-c"], root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"), + ("bash", ["--noprofile", "--norc", "-c"], root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"), + ] + scenarios = [ + "prompt_helper_idle", + "transient_same_context", + "branch_switch_clear", + "timeout_recovery", + ] + + base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}" + try: + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + + failures: list[str] = [] + for shell, shell_args, script in cases: + if not script.exists(): + print(f"SKIP: missing integration script at {script}") + continue + for scenario in scenarios: + rc, detail = _run_case( + base / f"{shell}-{scenario}", + shell=shell, + shell_args=shell_args, + script=script, + scenario=scenario, + ) + if rc != 0: + failures.append(detail) + + if failures: + print("FAIL:") + for failure in failures: + print(failure) + return 1 + + print("PASS: shell integrations poll PR state robustly across transient failures, branch changes, and timeouts") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_issue_464_cmdw_close_terminal_browser_split.py b/tests/test_issue_464_cmdw_close_terminal_browser_split.py new file mode 100644 index 00000000..90a15843 --- /dev/null +++ b/tests/test_issue_464_cmdw_close_terminal_browser_split.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +""" +Regression test for issue #464: + +Scenario: + - One workspace with exactly two panes: + left: terminal + right: browser (cnn.com) + - Focus the terminal and press Cmd+W. + +Expected: + - Terminal closes. + - Browser remains and fills the workspace (no stale terminal content/pane). + +This test uses debug socket commands (`simulate_shortcut`, `layout_debug`, +`surface_health`, `drag_hit_chain`). +Run against a Debug app socket (typically with CMUX_SOCKET_MODE=allowAll). +""" + +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) -> bool: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return True + time.sleep(interval_s) + return False + + +def _wait_url_contains(client: cmux, panel_id: str, needle: str, timeout_s: float = 20.0) -> None: + def _matches() -> bool: + response = client._send_command(f"get_url {panel_id}").strip().lower() + return not response.startswith("error") and needle.lower() in response + + if not _wait_until(_matches, timeout_s=timeout_s, interval_s=0.1): + current = client._send_command(f"get_url {panel_id}") + raise cmuxError(f"Timed out waiting for browser URL containing '{needle}', got: {current}") + + +def _capture_screenshot(client: cmux, label: str) -> str: + response = client._send_command(f"screenshot {label}").strip() + if not response.startswith("OK "): + return f"<unavailable: {response}>" + parts = response.split(" ", 2) + if len(parts) < 3: + return f"<unavailable: malformed response {response}>" + return parts[2] + + +def _focused_terminal_ready(client: cmux, panel_id: str) -> bool: + try: + return client.is_terminal_focused(panel_id) + except Exception: + return False + + +def _drag_hit_chain(client: cmux, nx: float, ny: float) -> str: + return client._send_command(f"drag_hit_chain {nx:.3f} {ny:.3f}").strip() + + +def _top_hit_view_class(hit_chain: str) -> str: + if not hit_chain or hit_chain == "none" or hit_chain.startswith("ERROR"): + return hit_chain + first = hit_chain.split("->", 1)[0] + return first.split("@", 1)[0] + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + # Quick sanity check: fail early with actionable info if socket is not in allow mode. + ping_ok = client.ping() + if not ping_ok: + raise cmuxError( + f"Socket ping failed on {SOCKET_PATH}. " + "Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test." + ) + + workspace_id = client.new_workspace() + try: + client.select_workspace(workspace_id) + time.sleep(0.25) + client.activate_app() + time.sleep(0.15) + + browser_id = client.new_pane( + direction="right", + panel_type="browser", + url="https://cnn.com", + ) + _wait_url_contains(client, browser_id, "cnn", timeout_s=20.0) + + health_before = client.surface_health() + terminal_rows = [row for row in health_before if row.get("type") == "terminal"] + browser_rows = [row for row in health_before if row.get("type") == "browser"] + if len(terminal_rows) != 1 or len(browser_rows) != 1: + raise cmuxError( + f"Expected exactly one terminal and one browser before close; " + f"health={health_before}" + ) + + terminal_id = terminal_rows[0]["id"] + client.focus_surface(terminal_id) + if not _wait_until(lambda: _focused_terminal_ready(client, terminal_id), timeout_s=4.0): + raise cmuxError(f"Terminal did not become first responder before Cmd+W: {terminal_id}") + + before_surfaces = client.list_surfaces() + before_panes = client.list_panes() + before_layout = client.layout_debug() + before_shot = _capture_screenshot(client, "issue464_cmdw_before") + + client.simulate_shortcut("cmd+w") + + # Give close animations/routing time to settle. + _wait_until(lambda: len(client.list_surfaces()) == 1, timeout_s=4.0, interval_s=0.05) + time.sleep(0.25) + + after_surfaces = client.list_surfaces() + after_panes = client.list_panes() + after_health = client.surface_health() + after_layout = client.layout_debug() + after_shot = _capture_screenshot(client, "issue464_cmdw_after") + after_hit_chain = _drag_hit_chain(client, 0.42, 0.50) + after_top_hit_class = _top_hit_view_class(after_hit_chain) + + failures: list[str] = [] + + if len(after_surfaces) != 1: + failures.append(f"Expected 1 surface after Cmd+W, got {len(after_surfaces)}: {after_surfaces}") + + if len(after_panes) != 1: + failures.append(f"Expected 1 pane after Cmd+W, got {len(after_panes)}: {after_panes}") + + visible_terminals = [ + row for row in after_health + if row.get("type") == "terminal" and row.get("in_window") is True + ] + if visible_terminals: + failures.append(f"Terminal still visible in_window after Cmd+W: {visible_terminals}") + + remaining_browsers = [row for row in after_health if row.get("type") == "browser"] + if len(remaining_browsers) != 1: + failures.append(f"Expected one remaining browser in health, got: {remaining_browsers}") + else: + rb = remaining_browsers[0] + if str(rb.get("id", "")).lower() != browser_id.lower(): + failures.append( + f"Remaining browser id mismatch: expected {browser_id}, got {rb.get('id')}" + ) + if rb.get("in_window") is not True: + failures.append(f"Remaining browser not in window: {rb}") + + selected_panels = after_layout.get("selectedPanels") or [] + if len(selected_panels) != 1: + failures.append(f"Expected one selected panel after close, got {selected_panels}") + else: + selected_id = str(selected_panels[0].get("panelId", "")).lower() + if selected_id != browser_id.lower(): + failures.append( + f"Selected panel mismatch after close: expected browser {browser_id}, got {selected_id}" + ) + + if after_top_hit_class == "GhosttyNSView": + failures.append( + "Stale terminal overlay still hit-testable after close " + f"(top_hit={after_top_hit_class}, chain={after_hit_chain})" + ) + + if failures: + details = [ + "Cmd+W close regression reproduced (issue #464).", + f"workspace={workspace_id}", + f"browser={browser_id}", + f"terminal={terminal_id}", + f"before_screenshot={before_shot}", + f"after_screenshot={after_shot}", + f"before_surfaces={before_surfaces}", + f"before_panes={before_panes}", + f"before_layout={before_layout}", + f"after_surfaces={after_surfaces}", + f"after_panes={after_panes}", + f"after_health={after_health}", + f"after_layout={after_layout}", + f"after_hit_chain={after_hit_chain}", + f"after_top_hit_class={after_top_hit_class}", + ] + details.extend(f"failure={msg}" for msg in failures) + raise cmuxError("\n".join(details)) + + print( + "PASS: Cmd+W closed terminal in terminal+browser split and left browser as sole visible pane." + ) + print(f"before_screenshot={before_shot}") + print(f"after_screenshot={after_shot}") + return 0 + finally: + try: + client.close_workspace(workspace_id) + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_issue_734_shell_integration_none_respected.py b/tests/test_issue_734_shell_integration_none_respected.py new file mode 100644 index 00000000..3fe6836c --- /dev/null +++ b/tests/test_issue_734_shell_integration_none_respected.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +Regression for issue #734: +cmux wrapper .zshenv should only source Ghostty zsh integration when Ghostty +actually enabled shell integration (signaled by GHOSTTY_ZSH_ZDOTDIR being set). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + + +def _run_case( + *, + wrapper_dir: Path, + home: Path, + orig_zdotdir: Path, + ghostty_resources: Path, + out_path: Path, + ghostty_enabled: bool, +) -> tuple[int, str]: + env = dict(os.environ) + env["HOME"] = str(home) + env["ZDOTDIR"] = str(wrapper_dir) + env["GHOSTTY_RESOURCES_DIR"] = str(ghostty_resources) + env["CMUX_SHELL_INTEGRATION"] = "0" + env["CMUX_TEST_OUT"] = str(out_path) + + # Keep input deterministic and local to this test. + for key in ( + "GHOSTTY_ZSH_ZDOTDIR", + "CMUX_ZSH_ZDOTDIR", + "CMUX_ORIGINAL_ZDOTDIR", + "GHOSTTY_SHELL_FEATURES", + "GHOSTTY_BIN_DIR", + ): + env.pop(key, None) + + if ghostty_enabled: + env["GHOSTTY_ZSH_ZDOTDIR"] = str(orig_zdotdir) + else: + env["CMUX_ZSH_ZDOTDIR"] = str(orig_zdotdir) + + result = subprocess.run( + ["zsh", "-d", "-i", "-c", "true"], + env=env, + capture_output=True, + text=True, + timeout=8, + ) + return (result.returncode, (result.stdout or "") + (result.stderr or "")) + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + wrapper_dir = root / "Resources" / "shell-integration" + if not (wrapper_dir / ".zshenv").exists(): + print(f"SKIP: missing wrapper .zshenv at {wrapper_dir}") + return 0 + + base = Path("/tmp") / f"cmux_issue_734_{os.getpid()}" + try: + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + + home = base / "home" + orig = base / "orig-zdotdir" + resources = base / "ghostty-resources" + home.mkdir(parents=True, exist_ok=True) + orig.mkdir(parents=True, exist_ok=True) + (resources / "shell-integration" / "zsh").mkdir(parents=True, exist_ok=True) + + # Keep user startup files inert and local. + for filename in (".zshenv", ".zprofile", ".zshrc"): + (orig / filename).write_text("", encoding="utf-8") + + marker = base / "ghostty-sourced.txt" + (resources / "shell-integration" / "zsh" / "ghostty-integration").write_text( + 'echo "sourced" >> "$CMUX_TEST_OUT"\n', + encoding="utf-8", + ) + + rc, out = _run_case( + wrapper_dir=wrapper_dir, + home=home, + orig_zdotdir=orig, + ghostty_resources=resources, + out_path=marker, + ghostty_enabled=False, + ) + if rc != 0: + print(f"FAIL: zsh exited non-zero when ghostty_enabled=False rc={rc}") + if out.strip(): + print(out.strip()) + return 1 + if marker.exists(): + print("FAIL: ghostty integration sourced when Ghostty shell integration was disabled") + return 1 + + rc, out = _run_case( + wrapper_dir=wrapper_dir, + home=home, + orig_zdotdir=orig, + ghostty_resources=resources, + out_path=marker, + ghostty_enabled=True, + ) + if rc != 0: + print(f"FAIL: zsh exited non-zero when ghostty_enabled=True rc={rc}") + if out.strip(): + print(out.strip()) + return 1 + if not marker.exists(): + print("FAIL: ghostty integration not sourced when Ghostty shell integration was enabled") + return 1 + + contents = marker.read_text(encoding="utf-8") + if "sourced" not in contents: + print("FAIL: expected marker output missing after enabled run") + return 1 + + print("PASS: wrapper respects Ghostty shell-integration=none via GHOSTTY_ZSH_ZDOTDIR gate") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_lint_swiftui_patterns.py b/tests/test_lint_swiftui_patterns.py deleted file mode 100644 index f5d82c14..00000000 --- a/tests/test_lint_swiftui_patterns.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -""" -Lint test to catch SwiftUI patterns that cause performance issues. - -This test checks for: -1. Text(_:style:) with auto-updating date styles (.time, .timer, .relative) - These cause continuous view updates and can lead to high CPU usage. -""" - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path -from typing import List, Tuple - - -def get_repo_root(): - """Get the repository root directory.""" - # Try git first - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - - # Fall back to finding GhosttyTabs directory - cwd = Path.cwd() - if cwd.name == "GhosttyTabs" or (cwd / "Sources").exists(): - return cwd - if (cwd.parent / "GhosttyTabs").exists(): - return cwd.parent / "GhosttyTabs" - - # Last resort: use current directory - return cwd - - -def find_swift_files(repo_root: Path) -> List[Path]: - """Find all Swift files in Sources directory (excluding vendored code).""" - sources_dir = repo_root / "Sources" - if not sources_dir.exists(): - return [] - return list(sources_dir.rglob("*.swift")) - - -def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, str]]: - """ - Check for Text(_:style:) with auto-updating date styles. - - These patterns cause continuous SwiftUI view updates: - - Text(date, style: .time) - updates every second/minute - - Text(date, style: .timer) - updates continuously - - Text(date, style: .relative) - updates periodically - - Text(date, style: .offset) - updates periodically - - Instead, use static formatting: - - Text(date.formatted(date: .omitted, time: .shortened)) - """ - violations = [] - - # Patterns that indicate auto-updating Text with Date - # The key patterns are: Text(something, style: .time/timer/relative/offset) - problematic_patterns = [ - "style: .time", - "style: .timer", - "style: .relative", - "style: .offset", - "style:.time", - "style:.timer", - "style:.relative", - "style:.offset", - ] - - for file_path in files: - try: - content = file_path.read_text() - lines = content.split('\n') - - for line_num, line in enumerate(lines, start=1): - # Skip comments - stripped = line.strip() - if stripped.startswith("//"): - continue - - for pattern in problematic_patterns: - if pattern in line: - violations.append((file_path, line_num, line.strip())) - break - except Exception as e: - print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr) - - return violations - - -def main(): - """Run the lint checks.""" - repo_root = get_repo_root() - swift_files = find_swift_files(repo_root) - - print(f"Checking {len(swift_files)} Swift files for performance issues...") - - # Check for auto-updating Text styles - violations = check_autoupdating_text_styles(swift_files) - - if violations: - 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: - rel_path = file_path.relative_to(repo_root) - print(f" {rel_path}:{line_num}") - print(f" {line}") - print() - - print("FIX: Replace with static formatting:") - print(" Instead of: Text(date, style: .time)") - print(" Use: Text(date.formatted(date: .omitted, time: .shortened))") - print() - return 1 - - print("✅ No auto-updating Text style patterns found") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_nightly_universal_build.sh b/tests/test_nightly_universal_build.sh new file mode 100644 index 00000000..e86dbf36 --- /dev/null +++ b/tests/test_nightly_universal_build.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Regression test for dual nightly macOS tracks. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW_FILE="$ROOT_DIR/.github/workflows/nightly.yml" + +if ! awk ' + /^ - name: Build Apple Silicon app \(Release\)/ { in_arm=1; next } + /^ - name: Build universal app \(Release\)/ { in_universal=1; next } + in_arm && /^ - name:/ { in_arm=0 } + in_universal && /^ - name:/ { in_universal=0 } + in_arm && /-destination '\''platform=macOS,arch=arm64'\''/ { saw_arm_destination=1 } + in_arm && /ARCHS="arm64"/ { saw_arm_archs=1 } + in_arm && /ONLY_ACTIVE_ARCH=YES/ { saw_arm_only_active_arch=1 } + in_universal && /-destination '\''generic\/platform=macOS'\''/ { saw_universal_destination=1 } + in_universal && /ARCHS="arm64 x86_64"/ { saw_universal_archs=1 } + in_universal && /ONLY_ACTIVE_ARCH=NO/ { saw_universal_only_active_arch=1 } + END { + exit !(saw_arm_destination && saw_arm_archs && saw_arm_only_active_arch && saw_universal_destination && saw_universal_archs && saw_universal_only_active_arch) + } +' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must force Apple Silicon nightly to arm64-only and universal nightly to both slices" + exit 1 +fi + +if ! awk ' + /^ - name: Verify nightly binary architectures/ { in_verify=1; next } + in_verify && /^ - name:/ { in_verify=0 } + in_verify && /lipo -archs "\$ARM_APP_BINARY"/ { saw_arm_app=1 } + in_verify && /lipo -archs "\$ARM_CLI_BINARY"/ { saw_arm_cli=1 } + in_verify && /lipo -archs "\$APP_BINARY"/ { saw_app=1 } + in_verify && /lipo -archs "\$CLI_BINARY"/ { saw_cli=1 } + in_verify && /\[\[ "\$ARM_APP_ARCHS" == "arm64" \]\]/ { saw_arm_app_assert=1 } + in_verify && /\[\[ "\$ARM_CLI_ARCHS" == "arm64" \]\]/ { saw_arm_cli_assert=1 } + END { exit !(saw_arm_app && saw_arm_cli && saw_app && saw_cli && saw_arm_app_assert && saw_arm_cli_assert) } +' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must verify arm-only and universal slices with lipo" + exit 1 +fi + +if ! grep -Fq 'com.cmuxterm.app.nightly.universal' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must set a distinct .universal bundle ID" + exit 1 +fi + +if ! grep -Fq 'https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast-universal.xml' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must publish a separate universal appcast feed" + exit 1 +fi + +if ! grep -Fq './scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must generate a separate universal appcast" + exit 1 +fi + +if ! grep -Fq "core.setOutput('should_publish', isMainRef ? 'true' : 'false');" "$WORKFLOW_FILE"; then + echo "FAIL: nightly decide step must expose should_publish based on whether the ref is main" + exit 1 +fi + +if ! awk ' + /^ - name: Upload branch nightly artifacts/ { in_upload=1; next } + in_upload && /^ - name:/ { in_upload=0 } + in_upload && /if: needs\.decide\.outputs\.should_publish != '\''true'\''/ { saw_if=1 } + in_upload && /uses: actions\/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4/ { saw_upload=1 } + in_upload && /cmux-nightly-macos\*\.dmg/ { saw_arm_artifacts=1 } + in_upload && /cmux-nightly-universal-macos\*\.dmg/ { saw_universal_artifacts=1 } + in_upload && /appcast-universal\.xml/ { saw_universal_appcast=1 } + END { exit !(saw_if && saw_upload && saw_arm_artifacts && saw_universal_artifacts && saw_universal_appcast) } +' "$WORKFLOW_FILE"; then + echo "FAIL: non-main nightly runs must upload both nightly variants and both appcasts" + exit 1 +fi + +if ! awk ' + /^ - name: Move nightly tag to built commit/ { in_move=1; next } + in_move && /^ - name:/ { in_move=0 } + in_move && /if: needs\.decide\.outputs\.should_publish == '\''true'\''/ { saw_move_if=1 } + END { exit !saw_move_if } +' "$WORKFLOW_FILE"; then + echo "FAIL: moving the nightly tag must be gated to main nightly publishes" + exit 1 +fi + +if ! awk ' + /^ - name: Publish nightly release assets/ { in_publish=1; next } + in_publish && /^ - name:/ { in_publish=0 } + in_publish && /if: needs\.decide\.outputs\.should_publish == '\''true'\''/ { saw_publish_if=1 } + in_publish && /cmux-nightly-universal-macos-\$\{\{ github\.run_id \}\}\*\.dmg/ { saw_universal_immutable=1 } + in_publish && /cmux-nightly-universal-macos\.dmg/ { saw_universal_stable=1 } + in_publish && /appcast-universal\.xml/ { saw_universal_appcast=1 } + END { exit !(saw_publish_if && saw_universal_immutable && saw_universal_stable && saw_universal_appcast) } +' "$WORKFLOW_FILE"; then + echo "FAIL: main nightly publish must include the universal assets and appcast" + exit 1 +fi + +echo "PASS: nightly workflow keeps separate Apple Silicon and universal nightly tracks" diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py new file mode 100755 index 00000000..b2b98a51 --- /dev/null +++ b/tests/test_open_wrapper.py @@ -0,0 +1,603 @@ +#!/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, + external_patterns: str | None = None, + fail_urls: list[str] | None = None, + local_files: list[str] | None = None, + python_bin: 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 + ;; + browserExternalOpenPatterns) + if [[ "${FAKE_DEFAULTS_EXTERNAL_PATTERNS+x}" == "x" ]]; then + printf '%s' "$FAKE_DEFAULTS_EXTERNAL_PATTERNS" + 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 +""", + ) + + if local_files: + for relative_path in local_files: + target = tmp / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("<!doctype html><title>fixture", encoding="utf-8") + + 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 python_bin is None: + env.pop("CMUX_OPEN_WRAPPER_PYTHON3", None) + else: + env["CMUX_OPEN_WRAPPER_PYTHON3"] = python_bin + + 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 external_patterns is None: + env.pop("FAKE_DEFAULTS_EXTERNAL_PATTERNS", None) + else: + env["FAKE_DEFAULTS_EXTERNAL_PATTERNS"] = external_patterns + + 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], + cwd=tmp, + 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_external_literal_pattern_is_deferred_to_app(failures: list[str]) -> None: + url = "https://platform.openai.com/account/usage" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + external_patterns="platform.openai.com/account/usage", + ) + expect(code == 0, f"external literal deferred: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {url}"], + f"external literal deferred: expected wrapper to pass URL to cmux, got {cmux_log}", + failures, + ) + expect( + open_log == [], + f"external literal deferred: system open should not be called by wrapper, got {open_log}", + failures, + ) + + +def test_external_regex_pattern_is_deferred_to_app(failures: list[str]) -> None: + url = "https://foo.example.com/billing" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="*.example.com", + external_patterns=r"re:^https?://[^/]*\.example\.com/(billing|usage)", + ) + expect(code == 0, f"external regex deferred: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {url}"], + f"external regex deferred: expected wrapper to pass URL to cmux, got {cmux_log}", + failures, + ) + expect( + open_log == [], + f"external regex deferred: system open should not be called by wrapper, got {open_log}", + failures, + ) + + +def test_external_regex_with_icu_features_is_deferred_to_app(failures: list[str]) -> None: + url = "https://example.com/usage/42" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="example.com", + external_patterns=r"re:^https://example\.com/usage/\d+$", + ) + expect(code == 0, f"external regex icu deferred: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {url}"], + f"external regex icu deferred: expected wrapper to pass URL to cmux, got {cmux_log}", + failures, + ) + expect( + open_log == [], + f"external regex icu deferred: system open should not be called by wrapper, got {open_log}", + failures, + ) + + +def test_external_invalid_regex_is_ignored_silently(failures: list[str]) -> None: + url = "https://example.com/path" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + external_patterns=r"re:[unclosed", + ) + expect(code == 0, f"external invalid regex: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {url}"], + f"external invalid regex: expected cmux open for {url}, got {cmux_log}", + failures, + ) + expect( + open_log == [], + f"external invalid regex: expected no system open calls, got {open_log}", + failures, + ) + expect( + "invalid regular expression" not in stderr.lower(), + f"external invalid regex: stderr should stay clean, got {stderr!r}", + 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_local_html_file_routes_to_cmux(failures: list[str]) -> None: + filename = "fixtures/hello page.HTML" + open_log, cmux_log, code, stderr = run_wrapper( + args=[filename], + intercept_setting="1", + whitelist="", + local_files=[filename], + ) + expect(code == 0, f"local html file: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"local html file: system open should not be called, got {open_log}", failures) + expect(len(cmux_log) == 1, f"local html file: expected exactly one cmux call, got {cmux_log}", failures) + if cmux_log: + expect( + cmux_log[0].startswith("browser open file://"), + f"local html file: expected file:// target, got {cmux_log[0]}", + failures, + ) + expect( + "hello%20page.HTML" in cmux_log[0], + f"local html file: expected URL-encoded filename in cmux target, got {cmux_log[0]}", + failures, + ) + + +def test_file_url_html_routes_to_cmux(failures: list[str]) -> None: + url = "file:///tmp/cmux-open-wrapper-fixture.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + ) + expect(code == 0, f"file url html: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"file url html: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"file url html: unexpected cmux log {cmux_log}", failures) + + +def test_file_url_html_routes_to_cmux_without_python_binary(failures: list[str]) -> None: + url = "file:///tmp/cmux-open-wrapper-fixture.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + python_bin="/definitely/missing/python3", + ) + expect(code == 0, f"file url html no-python fallback: wrapper exited {code}: {stderr}", failures) + expect( + open_log == [], + f"file url html no-python fallback: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"file url html no-python fallback: unexpected cmux log {cmux_log}", + failures, + ) + + +def test_local_html_file_routes_to_cmux_without_python_binary(failures: list[str]) -> None: + filename = "fixtures/no python fallback.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[filename], + intercept_setting="1", + whitelist="", + local_files=[filename], + python_bin="/definitely/missing/python3", + ) + expect(code == 0, f"local html no-python fallback: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"local html no-python fallback: system open should not be called, got {open_log}", failures) + expect( + len(cmux_log) == 1, + f"local html no-python fallback: expected exactly one cmux call, got {cmux_log}", + failures, + ) + if cmux_log: + expect( + cmux_log[0].startswith("browser open file://"), + f"local html no-python fallback: expected file:// target, got {cmux_log[0]}", + failures, + ) + expect( + "no%20python%20fallback.html" in cmux_log[0], + f"local html no-python fallback: expected URL-encoded filename, got {cmux_log[0]}", + failures, + ) + + +def test_domain_like_html_argument_passthrough(failures: list[str]) -> None: + arg = "example.com/report.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[arg], + intercept_setting="1", + whitelist="", + ) + expect(code == 0, f"domain-like html argument: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"domain-like html argument: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [arg], + f"domain-like html argument: expected system open [{arg}], got {open_log}", + failures, + ) + + +def test_non_file_scheme_html_passthrough(failures: list[str]) -> None: + url = "ftp://example.com/report.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + ) + expect(code == 0, f"non-file scheme html: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"non-file scheme html: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"non-file scheme html: expected system open [{url}], got {open_log}", failures) + + +def test_mailto_html_passthrough(failures: list[str]) -> None: + url = "mailto:help@example.com?subject=report.html" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + ) + expect(code == 0, f"mailto html: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"mailto html: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"mailto html: expected system open [{url}], got {open_log}", failures) + + +def test_local_non_html_file_passthrough(failures: list[str]) -> None: + filename = "fixtures/readme.md" + open_log, cmux_log, code, stderr = run_wrapper( + args=[filename], + intercept_setting="1", + whitelist="", + local_files=[filename], + ) + expect(code == 0, f"local non-html file: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"local non-html file: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [filename], f"local non-html file: expected system open [{filename}], got {open_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_external_literal_pattern_is_deferred_to_app(failures) + test_external_regex_pattern_is_deferred_to_app(failures) + test_external_regex_with_icu_features_is_deferred_to_app(failures) + test_external_invalid_regex_is_ignored_silently(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_local_html_file_routes_to_cmux(failures) + test_file_url_html_routes_to_cmux(failures) + test_file_url_html_routes_to_cmux_without_python_binary(failures) + test_local_html_file_routes_to_cmux_without_python_binary(failures) + test_domain_like_html_argument_passthrough(failures) + test_non_file_scheme_html_passthrough(failures) + test_mailto_html_passthrough(failures) + test_local_non_html_file_passthrough(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()) diff --git a/tests/test_session_restore_unfocused_workspace_multi_window_cycle.py b/tests/test_session_restore_unfocused_workspace_multi_window_cycle.py new file mode 100644 index 00000000..0f63b2b9 --- /dev/null +++ b/tests/test_session_restore_unfocused_workspace_multi_window_cycle.py @@ -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()) diff --git a/tests/test_session_restore_unfocused_workspace_relaunch_cycle.py b/tests/test_session_restore_unfocused_workspace_relaunch_cycle.py new file mode 100644 index 00000000..87164820 --- /dev/null +++ b/tests/test_session_restore_unfocused_workspace_relaunch_cycle.py @@ -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()) diff --git a/tests/test_shell_scrollback_restore_color_replay.py b/tests/test_shell_scrollback_restore_color_replay.py new file mode 100644 index 00000000..fed1088e --- /dev/null +++ b/tests/test_shell_scrollback_restore_color_replay.py @@ -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()) diff --git a/tests/test_shell_scrollback_restore_replay_path_regression.py b/tests/test_shell_scrollback_restore_replay_path_regression.py new file mode 100644 index 00000000..2f7d549e --- /dev/null +++ b/tests/test_shell_scrollback_restore_replay_path_regression.py @@ -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()) diff --git a/tests/test_sidebar_cwd_git.py b/tests/test_sidebar_cwd_git.py index e6168a1f..520a0831 100644 --- a/tests/test_sidebar_cwd_git.py +++ b/tests/test_sidebar_cwd_git.py @@ -72,6 +72,7 @@ def _wait_for_git_branch( expected: str, timeout: float = 12.0, interval: float = 0.15, + allow_force_fallback: bool = True, ) -> dict[str, str]: def pred(): state = _parse_sidebar_state(client.sidebar_state()) @@ -82,6 +83,8 @@ def _wait_for_git_branch( try: return _wait_for(pred, timeout=timeout, interval=interval, label=f"git_branch={expected!r}") except AssertionError as original_error: + if not allow_force_fallback: + raise original_error # VM shells can occasionally skip a prompt hook; force a one-shot report so # the remainder of the flow can still validate transition behavior. try: @@ -180,6 +183,18 @@ def main() -> int: _send_cd_and_wait(client, repo) _wait_for_git_branch(client, "main") + # Branch changes during a long-running foreground command should still + # propagate before the prompt returns (agent-style workflows). + client.send("bash -lc 'git checkout -b feature/agent-live >/dev/null 2>&1; sleep 6'\n") + _wait_for_git_branch( + client, + "feature/agent-live", + timeout=3.5, + interval=0.1, + allow_force_fallback=False, + ) + time.sleep(6.3) + # Branch change should update. # Cover alias/non-`git ...` command paths too (regression: branch could # stick for ~3s when switching via alias/tools like `gh pr checkout`). diff --git a/tests/test_sidebar_meta.py b/tests/test_sidebar_meta.py new file mode 100644 index 00000000..7d5af6f0 --- /dev/null +++ b/tests/test_sidebar_meta.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +End-to-end test for generic sidebar metadata commands. + +Validates: +1) report_meta stores icon/url/priority/format metadata +2) metadata list ordering follows priority +3) set_status remains compatible as an alias-style metadata writer +4) clear_meta removes metadata entries +""" + +from __future__ import annotations + +import os +import sys +import time + +# Add the directory containing cmux.py to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for_state_field( + client: cmux, + key: str, + expected: str, + timeout: float = 8.0, + interval: float = 0.1, +) -> dict[str, str]: + start = time.time() + while time.time() - start < timeout: + state = _parse_sidebar_state(client.sidebar_state()) + if state.get(key) == expected: + return state + time.sleep(interval) + raise AssertionError(f"Timed out waiting for {key}={expected!r}") + + +def main() -> int: + tag = os.environ.get("CMUX_TAG") or "" + if not tag: + print("Tip: set CMUX_TAG= when running this test to avoid socket conflicts.") + + pr_url = "https://github.com/manaflow-ai/cmux/pull/337" + + try: + with cmux() as client: + new_tab_id = client.new_tab() + client.select_tab(new_tab_id) + time.sleep(0.6) + + tab_id = client.current_workspace() + + client.report_meta( + "task", + "**Review** PR 337", + icon="sf:doc.text.magnifyingglass", + url=pr_url, + priority=50, + format="markdown", + tab=tab_id, + ) + client.report_meta( + "context", + "issue-336-sidebar-pr-metadata", + icon="text:CTX", + priority=10, + tab=tab_id, + ) + _wait_for_state_field(client, "status_count", "2") + + listed = client.list_meta(tab=tab_id).splitlines() + if len(listed) != 2: + raise AssertionError(f"Expected 2 metadata entries, got {len(listed)}: {listed}") + + if not listed[0].startswith("task="): + raise AssertionError(f"Expected first entry to be task metadata. Got: {listed[0]}") + if "priority=50" not in listed[0]: + raise AssertionError(f"Expected task entry to include priority. Got: {listed[0]}") + if "format=markdown" not in listed[0]: + raise AssertionError(f"Expected markdown format in task entry. Got: {listed[0]}") + if f"url={pr_url}" not in listed[0]: + raise AssertionError(f"Expected URL in task entry. Got: {listed[0]}") + + client.set_status("agent", "in progress", icon="text:AI", priority=80, tab=tab_id) + _wait_for_state_field(client, "status_count", "3") + + listed = client.list_meta(tab=tab_id).splitlines() + if not listed[0].startswith("agent="): + raise AssertionError(f"Expected highest-priority agent entry first. Got: {listed[0]}") + + client.clear_meta("task", tab=tab_id) + _wait_for_state_field(client, "status_count", "2") + + listed = client.list_meta(tab=tab_id).splitlines() + if any(line.startswith("task=") for line in listed): + raise AssertionError(f"Task metadata should be cleared. Got: {listed}") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar metadata test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar metadata test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sidebar_meta_block.py b/tests/test_sidebar_meta_block.py new file mode 100644 index 00000000..1ca6ade1 --- /dev/null +++ b/tests/test_sidebar_meta_block.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +End-to-end test for sidebar markdown metadata block commands. + +Validates: +1) report_meta_block stores markdown payload and priority +2) metadata block list ordering follows priority +3) clear_meta_block removes block metadata +""" + +from __future__ import annotations + +import os +import sys +import time + +# Add the directory containing cmux.py to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for_state_field( + client: cmux, + key: str, + expected: str, + timeout: float = 8.0, + interval: float = 0.1, +) -> dict[str, str]: + start = time.time() + while time.time() - start < timeout: + state = _parse_sidebar_state(client.sidebar_state()) + if state.get(key) == expected: + return state + time.sleep(interval) + raise AssertionError(f"Timed out waiting for {key}={expected!r}") + + +def main() -> int: + tag = os.environ.get("CMUX_TAG") or "" + if not tag: + print("Tip: set CMUX_TAG= when running this test to avoid socket conflicts.") + + try: + with cmux() as client: + new_tab_id = client.new_tab() + client.select_tab(new_tab_id) + time.sleep(0.6) + + tab_id = client.current_workspace() + + summary_md = "### Agent\\n- status: in progress\\n- pr: #337" + footer_md = "_last update: now_" + + client.report_meta_block("summary", summary_md, priority=50, tab=tab_id) + client.report_meta_block("footer", footer_md, priority=10, tab=tab_id) + _wait_for_state_field(client, "meta_block_count", "2") + + listed = client.list_meta_blocks(tab=tab_id).splitlines() + if len(listed) != 2: + raise AssertionError(f"Expected 2 metadata blocks, got {len(listed)}: {listed}") + if not listed[0].startswith("summary="): + raise AssertionError(f"Expected highest-priority block first. Got: {listed[0]}") + if "priority=50" not in listed[0]: + raise AssertionError(f"Expected summary block priority in listing. Got: {listed[0]}") + + client.clear_meta_block("summary", tab=tab_id) + _wait_for_state_field(client, "meta_block_count", "1") + + listed = client.list_meta_blocks(tab=tab_id).splitlines() + if any(line.startswith("summary=") for line in listed): + raise AssertionError(f"Summary block should be cleared. Got: {listed}") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar markdown metadata block test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar markdown metadata block test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sidebar_pr.py b/tests/test_sidebar_pr.py new file mode 100644 index 00000000..39645aaa --- /dev/null +++ b/tests/test_sidebar_pr.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +End-to-end test for sidebar pull-request metadata. + +Validates: +1) report_pr writes sidebar PR state +2) state transition open -> merged is reflected +3) provider labels can be set via report_review/report_pr --label +4) clear_pr removes PR metadata +""" + +from __future__ import annotations + +import os +import sys +import time + +# Add the directory containing cmux.py to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for_state_field( + client: cmux, + key: str, + expected: str, + timeout: float = 8.0, + interval: float = 0.1, +) -> dict[str, str]: + start = time.time() + while time.time() - start < timeout: + state = _parse_sidebar_state(client.sidebar_state()) + if state.get(key) == expected: + return state + time.sleep(interval) + raise AssertionError(f"Timed out waiting for {key}={expected!r}") + + +def main() -> int: + tag = os.environ.get("CMUX_TAG") or "" + if not tag: + print("Tip: set CMUX_TAG= when running this test to avoid socket conflicts.") + + pr_number = 123 + pr_url = f"https://github.com/manaflow-ai/cmux/pull/{pr_number}" + + try: + with cmux() as client: + new_tab_id = client.new_tab() + client.select_tab(new_tab_id) + time.sleep(0.6) + + tab_id = client.current_workspace() + surfaces = client.list_surfaces() + if not surfaces: + raise AssertionError("No surfaces found in selected workspace") + panel_id = surfaces[0][1] + + client.report_pr(pr_number, pr_url, state="open", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}") + _wait_for_state_field(client, "pr_label", "PR") + + client.report_review(pr_number, pr_url, label="MR", state="open", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}") + _wait_for_state_field(client, "pr_label", "MR") + + client.report_pr(pr_number, pr_url, state="merged", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} merged {pr_url}") + _wait_for_state_field(client, "pr_label", "PR") + + client.clear_pr(tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", "none") + _wait_for_state_field(client, "pr_label", "none") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar PR metadata test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar PR metadata test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_split_cwd_inheritance.py b/tests/test_split_cwd_inheritance.py new file mode 100644 index 00000000..6677ee8e --- /dev/null +++ b/tests/test_split_cwd_inheritance.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +End-to-end test for split CWD inheritance. + +Verifies that new split panes and new workspace tabs inherit the current +working directory from the source terminal. + +Requires: + - cmux running with allowAll socket mode + - bash shell integration sourced (cmux-bash-integration.bash) + +Run with a tagged instance: + CMUX_TAG= python3 tests/test_split_cwd_inheritance.py +""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for(predicate, timeout: float, interval: float, label: str): + start = time.time() + last_error: Exception | None = None + while time.time() - start < timeout: + try: + value = predicate() + if value: + return value + except Exception as e: + last_error = e + time.sleep(interval) + extra = "" + if last_error is not None: + extra = f" Last error: {last_error}" + raise AssertionError(f"Timed out waiting for {label}.{extra}") + + +def _wait_for_focused_cwd( + client: cmux, + expected: str, + timeout: float = 12.0, + exclude_panel: str | None = None, +) -> dict[str, str]: + """Wait for focused_cwd to match expected. + + If exclude_panel is given, also require that focused_panel differs from + that value — ensuring we're checking the *new* pane, not the original. + """ + def pred(): + state = _parse_sidebar_state(client.sidebar_state()) + cwd = state.get("focused_cwd", "") + if cwd != expected: + return None + if exclude_panel and state.get("focused_panel", "") == exclude_panel: + return None + return state + label = f"focused_cwd={expected!r}" + if exclude_panel: + label += f" (panel != {exclude_panel})" + return _wait_for(pred, timeout=timeout, interval=0.3, label=label) + + +def _send_cd_and_wait( + client: cmux, + target: str, + timeout: float = 12.0, +) -> dict[str, str]: + """cd to target and wait for sidebar focused_cwd to reflect it.""" + client.send(f"cd {target}\n") + return _wait_for_focused_cwd(client, target, timeout=timeout) + + +def main() -> int: + tag = os.environ.get("CMUX_TAG", "") + + socket_path = None + if tag: + socket_path = f"/tmp/cmux-debug-{tag}.sock" + client = cmux(socket_path=socket_path) + client.connect() + + # Use resolved paths to avoid /tmp -> /private/tmp symlink mismatch on macOS + test_dir_a = str(Path("/tmp/cmux_split_cwd_test_a").resolve()) + test_dir_b = str(Path("/tmp/cmux_split_cwd_test_b").resolve()) + os.makedirs(test_dir_a, exist_ok=True) + os.makedirs(test_dir_b, exist_ok=True) + + passed = 0 + failed = 0 + + def check(name: str, condition: bool, detail: str = ""): + nonlocal passed, failed + if condition: + print(f" PASS {name}") + passed += 1 + else: + print(f" FAIL {name}{': ' + detail if detail else ''}") + failed += 1 + + print("=== Split CWD Inheritance Tests ===") + + # --- Setup: cd to test_dir_a in workspace 1 --- + print(" [setup] cd to test_dir_a and wait for shell integration...") + _send_cd_and_wait(client, test_dir_a) + state = _parse_sidebar_state(client.sidebar_state()) + check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a, + f"got {state.get('focused_cwd')!r}") + + # --- Test 1: New split inherits test_dir_a --- + print(" [test1] creating right split from test_dir_a...") + # Record the original panel so we can verify focus moves to the NEW pane. + original_panel = state.get("focused_panel", "") + split_result = client.new_split("right") + if not split_result: + check("split created", False) + print(f"\n{passed} passed, {failed} failed") + client.close() + return 1 + check("split created", True) + + # Wait for the NEW pane (different panel ID) to report test_dir_a. + time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND + try: + state = _wait_for_focused_cwd( + client, test_dir_a, timeout=15.0, exclude_panel=original_panel, + ) + new_panel = state.get("focused_panel", "") + check("test1: focus moved to new pane", new_panel != original_panel, + f"original={original_panel!r}, current={new_panel!r}") + check("test1: split inherited test_dir_a", + state.get("focused_cwd") == test_dir_a, + f"focused_cwd={state.get('focused_cwd')!r}") + except AssertionError: + state = _parse_sidebar_state(client.sidebar_state()) + check("test1: split inherited test_dir_a", False, + f"focused_cwd={state.get('focused_cwd')!r}, focused_panel={state.get('focused_panel')!r}") + + # --- Test 2: New workspace tab inherits CWD --- + # First cd to test_dir_b so we have a different dir to inherit + print(" [test2] cd to test_dir_b, then creating new workspace tab...") + _send_cd_and_wait(client, test_dir_b) + state = _parse_sidebar_state(client.sidebar_state()) + original_tab = state.get("tab", "") + + tab_result = client.new_tab() + if not tab_result: + check("new tab created", False) + print(f"\n{passed} passed, {failed} failed") + client.close() + return 1 + check("new tab created", True) + + # New workspace should be a different tab AND inherit test_dir_b + time.sleep(4) + try: + def _new_tab_with_cwd(): + s = _parse_sidebar_state(client.sidebar_state()) + tab_id = s.get("tab", "") + cwd = s.get("focused_cwd", "") + if tab_id != original_tab and cwd == test_dir_b: + return s + return None + + state = _wait_for( + _new_tab_with_cwd, timeout=15.0, interval=0.3, + label=f"new tab with focused_cwd={test_dir_b!r}", + ) + check("test2: focus moved to new tab", state.get("tab") != original_tab, + f"original={original_tab!r}, current={state.get('tab')!r}") + check("test2: new workspace inherited test_dir_b", + state.get("focused_cwd") == test_dir_b, + f"focused_cwd={state.get('focused_cwd')!r}") + except AssertionError: + state = _parse_sidebar_state(client.sidebar_state()) + check("test2: new workspace inherited test_dir_b", False, + f"focused_cwd={state.get('focused_cwd')!r}, tab={state.get('tab')!r}") + + print(f"\n{passed} passed, {failed} failed") + + client.close() + + # Cleanup + for d in [test_dir_a, test_dir_b]: + try: + os.rmdir(d) + except OSError: + pass + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_update_timing.py b/tests/test_update_timing.py deleted file mode 100644 index eea8b34f..00000000 --- a/tests/test_update_timing.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -""" -Verify update UI timing constants so update indicators are visible long enough. -""" - -from pathlib import Path -import re -import sys - - -ROOT = Path(__file__).resolve().parents[1] -TIMING_FILE = ROOT / "Sources" / "Update" / "UpdateTiming.swift" - - -def read_constants(text: str) -> dict[str, float]: - constants = {} - pattern = re.compile(r"static let (\w+): TimeInterval = ([0-9.]+)") - for match in pattern.finditer(text): - constants[match.group(1)] = float(match.group(2)) - return constants - - -def main() -> int: - if not TIMING_FILE.exists(): - print(f"Missing {TIMING_FILE}") - return 1 - - constants = read_constants(TIMING_FILE.read_text()) - required = { - "minimumCheckDisplayDuration": 2.0, - "noUpdateDisplayDuration": 5.0, - } - - failures = [] - for name, expected in required.items(): - actual = constants.get(name) - if actual is None: - failures.append(f"{name} missing") - continue - if actual != expected: - failures.append(f"{name} = {actual} (expected {expected})") - - if failures: - print("Update timing test failed:") - for failure in failures: - print(f" - {failure}") - return 1 - - print("Update timing test passed.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_workspace_churn_up_arrow_lag.py b/tests/test_workspace_churn_up_arrow_lag.py new file mode 100755 index 00000000..3cadc43d --- /dev/null +++ b/tests/test_workspace_churn_up_arrow_lag.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +""" +Regression harness: compare typing latency before and after workspace churn. + +Scenario A (baseline): +1) Keep only the first workspace. +2) Seed shell history. +3) Measure per-key latency for repeated Up-arrow shortcuts. + +Scenario B (churn): +1) Keep only the first workspace. +2) Create N workspaces. +3) Visit every workspace (simulates clicking each tab), then return to the first. +4) Seed shell history. +5) Measure Up-arrow latency again. + +The test fails when churn latency regresses too far relative to baseline. +""" + +from __future__ import annotations + +import os +import select +import socket +import statistics +import subprocess +import sys +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from cmux import cmux, cmuxError + +NEW_WORKSPACES = int(os.environ.get("CMUX_LAG_NEW_WORKSPACES", "20")) +SWITCH_PASSES = int(os.environ.get("CMUX_LAG_SWITCH_PASSES", "1")) +SWITCH_DELAY_S = float(os.environ.get("CMUX_LAG_SWITCH_DELAY_S", "0.06")) +HISTORY_SEED_LINES = int(os.environ.get("CMUX_LAG_HISTORY_LINES", "120")) +KEY_EVENTS = int(os.environ.get("CMUX_LAG_KEY_EVENTS", "180")) +KEY_DELAY_S = float(os.environ.get("CMUX_LAG_KEY_DELAY_S", "0.0")) +KEY_COMBO = os.environ.get("CMUX_LAG_KEY_COMBO", "up") + +MAX_P95_RATIO = float(os.environ.get("CMUX_LAG_MAX_P95_RATIO", "1.70")) +MAX_AVG_RATIO = float(os.environ.get("CMUX_LAG_MAX_AVG_RATIO", "1.70")) +MAX_CHURN_P95_MS = float(os.environ.get("CMUX_LAG_MAX_CHURN_P95_MS", "35.0")) +MAX_P95_DELTA_MS = float(os.environ.get("CMUX_LAG_MAX_P95_DELTA_MS", "20.0")) +MAX_AVG_DELTA_MS = float(os.environ.get("CMUX_LAG_MAX_AVG_DELTA_MS", "12.0")) +MIN_BASELINE_P95_MS_FOR_RATIO = float(os.environ.get("CMUX_LAG_MIN_BASELINE_P95_MS_FOR_RATIO", "6.0")) +MIN_BASELINE_AVG_MS_FOR_RATIO = float(os.environ.get("CMUX_LAG_MIN_BASELINE_AVG_MS_FOR_RATIO", "4.0")) +MAX_CPU_PERCENT = float(os.environ.get("CMUX_LAG_MAX_CPU_PERCENT", "180.0")) +ENFORCE_CPU = os.environ.get("CMUX_LAG_ENFORCE_CPU", "0") == "1" +ALLOW_MAIN_SOCKET = os.environ.get("CMUX_LAG_ALLOW_MAIN_SOCKET", "0") == "1" + + +@dataclass +class LatencyStats: + n: int + avg_ms: float + p50_ms: float + p95_ms: float + p99_ms: float + max_ms: float + + +class RawSocketClient: + def __init__(self, socket_path: str): + self.socket_path = socket_path + self.sock: Optional[socket.socket] = None + self.recv_buffer = "" + + def connect(self) -> None: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(3.0) + sock.connect(self.socket_path) + self.sock = sock + + def close(self) -> None: + if self.sock is not None: + try: + self.sock.close() + finally: + self.sock = None + + def __enter__(self) -> RawSocketClient: + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + def command(self, command: str, timeout_s: float = 2.0) -> str: + if self.sock is None: + raise cmuxError("Raw socket client not connected") + + self.sock.sendall((command + "\n").encode("utf-8")) + deadline = time.time() + timeout_s + + while True: + if "\n" in self.recv_buffer: + line, self.recv_buffer = self.recv_buffer.split("\n", 1) + return line + + remaining = deadline - time.time() + if remaining <= 0: + raise cmuxError(f"Timed out waiting for response to: {command}") + + ready, _, _ = select.select([self.sock], [], [], remaining) + if not ready: + raise cmuxError(f"Timed out waiting for response to: {command}") + + chunk = self.sock.recv(8192) + if not chunk: + raise cmuxError("Socket closed while waiting for response") + self.recv_buffer += chunk.decode("utf-8", errors="replace") + + +def wait_for(predicate: Callable[[], bool], timeout_s: float, step_s: float = 0.05) -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def percentile(values: list[float], p: float) -> float: + if not values: + return 0.0 + if len(values) == 1: + return values[0] + sorted_values = sorted(values) + idx = (len(sorted_values) - 1) * p + lower = int(idx) + upper = min(lower + 1, len(sorted_values) - 1) + fraction = idx - lower + return sorted_values[lower] * (1 - fraction) + sorted_values[upper] * fraction + + +def compute_stats(values_ms: list[float]) -> LatencyStats: + return LatencyStats( + n=len(values_ms), + avg_ms=statistics.mean(values_ms) if values_ms else 0.0, + p50_ms=percentile(values_ms, 0.50), + p95_ms=percentile(values_ms, 0.95), + p99_ms=percentile(values_ms, 0.99), + max_ms=max(values_ms) if values_ms else 0.0, + ) + + +def get_cmux_pid_for_socket(socket_path: Optional[str]) -> Optional[int]: + if socket_path and os.path.exists(socket_path): + result = subprocess.run(["lsof", "-t", socket_path], capture_output=True, text=True) + if result.returncode == 0: + for line in result.stdout.strip().split("\n"): + line = line.strip() + if not line: + continue + try: + pid = int(line) + except ValueError: + continue + if pid != os.getpid(): + return pid + + result = subprocess.run( + ["pgrep", "-f", r"cmux DEV.*\.app/Contents/MacOS/cmux DEV"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + lines = [line.strip() for line in result.stdout.splitlines() if line.strip()] + return int(lines[0]) if lines else None + + +def resolve_target_socket() -> str: + socket_path = os.environ.get("CMUX_SOCKET_PATH") + if not socket_path: + raise cmuxError( + "CMUX_SOCKET_PATH is required. Point it to a tagged dev socket (for example /tmp/cmux-debug-.sock)." + ) + base = os.path.basename(socket_path) + if not ALLOW_MAIN_SOCKET and base in {"cmux.sock", "cmux-debug.sock"}: + raise cmuxError( + f"Refusing to run against main socket '{socket_path}'. Set CMUX_SOCKET_PATH to a tagged dev instance." + ) + return socket_path + + +def get_cpu(pid: int) -> float: + result = subprocess.run(["ps", "-p", str(pid), "-o", "%cpu="], capture_output=True, text=True) + if result.returncode != 0: + return 0.0 + try: + return float(result.stdout.strip()) + except ValueError: + return 0.0 + + +class CPUMonitor: + def __init__(self, pid: int, interval_s: float = 0.2): + self.pid = pid + self.interval_s = interval_s + self._stop = threading.Event() + self._thread = threading.Thread(target=self._run, daemon=True) + self.samples: list[float] = [] + + def _run(self) -> None: + while not self._stop.is_set(): + self.samples.append(get_cpu(self.pid)) + time.sleep(self.interval_s) + + def start(self) -> None: + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self._thread.join(timeout=2.0) + + +def keep_only_first_workspace(client: cmux) -> str: + workspaces = sorted(client.list_workspaces(), key=lambda row: row[0]) + if not workspaces: + first_id = client.new_workspace() + client.select_workspace(first_id) + return first_id + + first_id = workspaces[0][1] + client.select_workspace(first_id) + for _index, wid, _title, _selected in reversed(workspaces[1:]): + if wid == first_id: + continue + client.close_workspace(wid) + + def only_first() -> bool: + current = sorted(client.list_workspaces(), key=lambda row: row[0]) + return len(current) == 1 and current[0][1] == first_id + + wait_for(only_first, timeout_s=6.0) + return first_id + + +def create_workspaces(client: cmux, count: int) -> list[str]: + created: list[str] = [] + for _ in range(count): + wid = client.new_workspace() + created.append(wid) + time.sleep(0.04) + return created + + +def cycle_all_workspaces(client: cmux, passes: int, delay_s: float) -> list[str]: + ids = [wid for _idx, wid, _title, _selected in sorted(client.list_workspaces(), key=lambda row: row[0])] + for _ in range(passes): + for wid in ids: + client.select_workspace(wid) + time.sleep(delay_s) + return ids + + +def focused_terminal_panel(client: cmux) -> str: + surfaces = client.list_surfaces() + if not surfaces: + raise cmuxError("No surfaces available in selected workspace") + focused = next(((idx, sid) for idx, sid, is_focused in surfaces if is_focused), None) + if focused is None: + idx, sid, _ = surfaces[0] + client.focus_surface(idx) + return sid + return focused[1] + + +def seed_history(client: cmux, lines: int) -> None: + for i in range(lines): + client.send_line(f"echo cmux-lag-seed-{i}") + + +def run_shortcut_latency_burst( + socket_path: str, + combo: str, + count: int, + delay_s: float, +) -> list[float]: + latencies_ms: list[float] = [] + with RawSocketClient(socket_path) as raw: + # Warm up the command path and responder chain. + for _ in range(5): + response = raw.command(f"simulate_shortcut {combo}") + if not response.startswith("OK"): + raise cmuxError(response) + + for _ in range(count): + start = time.perf_counter() + response = raw.command(f"simulate_shortcut {combo}") + elapsed_ms = (time.perf_counter() - start) * 1000.0 + if not response.startswith("OK"): + raise cmuxError(response) + latencies_ms.append(elapsed_ms) + if delay_s > 0: + time.sleep(delay_s) + + return latencies_ms + + +def maybe_write_sample(pid: Optional[int], prefix: str) -> Optional[Path]: + if pid is None: + return None + out = Path(f"/tmp/{prefix}_{pid}.txt") + result = subprocess.run(["sample", str(pid), "2"], capture_output=True, text=True) + out.write_text(result.stdout + result.stderr) + return out + + +def print_stats(label: str, stats: LatencyStats) -> None: + print(f"\n{label}") + print(f" events: {stats.n}") + print(f" avg_ms: {stats.avg_ms:.2f}") + print(f" p50_ms: {stats.p50_ms:.2f}") + print(f" p95_ms: {stats.p95_ms:.2f}") + print(f" p99_ms: {stats.p99_ms:.2f}") + print(f" max_ms: {stats.max_ms:.2f}") + + +def run_baseline_scenario(client: cmux, socket_path: str) -> tuple[str, LatencyStats]: + first_workspace_id = keep_only_first_workspace(client) + client.select_workspace(first_workspace_id) + panel_id = focused_terminal_panel(client) + seed_history(client, HISTORY_SEED_LINES) + latencies = run_shortcut_latency_burst( + socket_path=socket_path, + combo=KEY_COMBO, + count=KEY_EVENTS, + delay_s=KEY_DELAY_S, + ) + return panel_id, compute_stats(latencies) + + +def run_churn_scenario(client: cmux, socket_path: str, first_workspace_id: str) -> tuple[str, LatencyStats]: + first_workspace_id = keep_only_first_workspace(client) + _ = create_workspaces(client, NEW_WORKSPACES) + ordered_ids = cycle_all_workspaces(client, SWITCH_PASSES, SWITCH_DELAY_S) + + if first_workspace_id in ordered_ids: + client.select_workspace(first_workspace_id) + elif ordered_ids: + client.select_workspace(ordered_ids[0]) + + panel_id = focused_terminal_panel(client) + seed_history(client, HISTORY_SEED_LINES) + latencies = run_shortcut_latency_burst( + socket_path=socket_path, + combo=KEY_COMBO, + count=KEY_EVENTS, + delay_s=KEY_DELAY_S, + ) + return panel_id, compute_stats(latencies) + + +def main() -> int: + print("=" * 64) + print("Workspace Churn + Up-Arrow Latency Regression") + print("=" * 64) + + client: Optional[cmux] = None + pid: Optional[int] = None + first_workspace_id: Optional[str] = None + + try: + target_socket = resolve_target_socket() + client = cmux(socket_path=target_socket) + client.connect() + print(f"Using socket: {client.socket_path}") + + pid = get_cmux_pid_for_socket(client.socket_path) + if pid is None: + print("SKIP: cmux process not found for socket") + return 0 + + cpu_monitor = CPUMonitor(pid) + cpu_monitor.start() + + first_workspace_id = keep_only_first_workspace(client) + baseline_panel_id, baseline = run_baseline_scenario(client, client.socket_path) + print(f"Baseline panel: {baseline_panel_id}") + + churn_panel_id, churn = run_churn_scenario(client, client.socket_path, first_workspace_id) + print(f"Churn panel: {churn_panel_id}") + + cpu_monitor.stop() + cpu_samples = cpu_monitor.samples + cpu_avg = statistics.mean(cpu_samples) if cpu_samples else 0.0 + cpu_max = max(cpu_samples) if cpu_samples else 0.0 + + print_stats("Baseline", baseline) + print_stats("After workspace churn", churn) + + p95_ratio = churn.p95_ms / max(baseline.p95_ms, 0.001) + avg_ratio = churn.avg_ms / max(baseline.avg_ms, 0.001) + p95_delta_ms = churn.p95_ms - baseline.p95_ms + avg_delta_ms = churn.avg_ms - baseline.avg_ms + enforce_p95_ratio = baseline.p95_ms >= MIN_BASELINE_P95_MS_FOR_RATIO + enforce_avg_ratio = baseline.avg_ms >= MIN_BASELINE_AVG_MS_FOR_RATIO + + print("\nComparison") + print( + f" p95_ratio: {p95_ratio:.2f}x (max {MAX_P95_RATIO:.2f}x, " + f"enabled when baseline p95 >= {MIN_BASELINE_P95_MS_FOR_RATIO:.2f}ms)" + ) + print( + f" avg_ratio: {avg_ratio:.2f}x (max {MAX_AVG_RATIO:.2f}x, " + f"enabled when baseline avg >= {MIN_BASELINE_AVG_MS_FOR_RATIO:.2f}ms)" + ) + print(f" churn_p95_ms: {churn.p95_ms:.2f} (max {MAX_CHURN_P95_MS:.2f})") + print(f" p95_delta_ms: {p95_delta_ms:.2f} (max {MAX_P95_DELTA_MS:.2f})") + print(f" avg_delta_ms: {avg_delta_ms:.2f} (max {MAX_AVG_DELTA_MS:.2f})") + print(f" cpu_avg_pct: {cpu_avg:.2f}") + print(f" cpu_max_pct: {cpu_max:.2f}") + + failures: list[str] = [] + if enforce_p95_ratio and p95_ratio > MAX_P95_RATIO: + failures.append(f"p95 ratio {p95_ratio:.2f}x > {MAX_P95_RATIO:.2f}x") + if enforce_avg_ratio and avg_ratio > MAX_AVG_RATIO: + failures.append(f"avg ratio {avg_ratio:.2f}x > {MAX_AVG_RATIO:.2f}x") + if p95_delta_ms > MAX_P95_DELTA_MS: + failures.append(f"p95 delta {p95_delta_ms:.2f}ms > {MAX_P95_DELTA_MS:.2f}ms") + if avg_delta_ms > MAX_AVG_DELTA_MS: + failures.append(f"avg delta {avg_delta_ms:.2f}ms > {MAX_AVG_DELTA_MS:.2f}ms") + if churn.p95_ms > MAX_CHURN_P95_MS: + failures.append(f"churn p95 {churn.p95_ms:.2f}ms > {MAX_CHURN_P95_MS:.2f}ms") + if ENFORCE_CPU and cpu_max > MAX_CPU_PERCENT: + failures.append(f"cpu max {cpu_max:.2f}% > {MAX_CPU_PERCENT:.2f}%") + + if failures: + print("\nFAIL") + for item in failures: + print(f" - {item}") + sample_path = maybe_write_sample(pid, "cmux_workspace_churn_up_arrow_lag") + if sample_path: + print(f" sample_path: {sample_path}") + return 1 + + print("\nPASS") + return 0 + + except cmuxError as e: + print(f"FAIL: {e}") + sample_path = maybe_write_sample(pid, "cmux_workspace_churn_up_arrow_error") + if sample_path: + print(f"sample_path: {sample_path}") + return 1 + + finally: + if client is not None: + try: + if first_workspace_id: + client.select_workspace(first_workspace_id) + keep_only_first_workspace(client) + except Exception: + pass + client.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/cmux.py b/tests_v2/cmux.py index cf94aae2..18af2284 100755 --- a/tests_v2/cmux.py +++ b/tests_v2/cmux.py @@ -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 {} diff --git a/tests_v2/test_browser_cli_agent_port.py b/tests_v2/test_browser_cli_agent_port.py index d8266a66..d3cbdf99 100644 --- a/tests_v2/test_browser_cli_agent_port.py +++ b/tests_v2/test_browser_cli_agent_port.py @@ -91,6 +91,32 @@ def _run_cli_text(cli: str, args: list[str], retries: int = 3) -> str: raise cmuxError(f"CLI failed ({' '.join(args)}): {last_merged}") + +def _run_cli_tail_json(cli: str, args: list[str], retries: int = 3) -> dict: + last_merged = "" + for attempt in range(1, retries + 1): + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH] + args, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode == 0: + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid CLI JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + merged = f"{proc.stdout}\n{proc.stderr}".strip() + last_merged = merged + if "Command timed out" in merged and attempt < retries: + time.sleep(0.2) + continue + raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}") + + raise cmuxError(f"CLI failed ({' '.join(args)}): {last_merged}") + + def _run_cli_expect_failure(cli: str, args: list[str], needles: list[str]) -> None: proc = subprocess.run( [cli, "--socket", SOCKET_PATH, "--json"] + args, @@ -144,15 +170,6 @@ def main() -> int: cli = _find_cli_binary() with _local_test_server() as page_url: - opened = _run_cli_json(cli, ["browser", "open", page_url]) - surface = str(opened.get("surface_ref") or opened.get("surface_id") or "") - _must(bool(surface), f"browser open returned no surface handle: {opened}") - _must(surface.startswith("surface:"), f"Expected short surface ref from browser open, got: {opened}") - - _run_cli_json(cli, ["browser", surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"]) - snapshot_text = _run_cli_text(cli, ["browser", surface, "snapshot", "--interactive"]) - _must("ref=e" in snapshot_text, f"Expected snapshot text with refs from CLI: {snapshot_text!r}") - identify = _run_cli_json(cli, ["identify"]) focused = identify.get("focused") or {} workspace = str( @@ -163,6 +180,34 @@ def main() -> int: or "" ) _must(bool(workspace), f"Expected workspace handle from identify: {identify}") + os.environ["CMUX_WORKSPACE_ID"] = workspace + + opened_tail_json = _run_cli_tail_json( + cli, + ["browser", "open", page_url, "--workspace", workspace, "--id-format", "both", "--json"], + ) + tail_surface = str(opened_tail_json.get("surface_ref") or "") + _must(tail_surface.startswith("surface:"), f"Expected trailing --json browser open to return surface_ref: {opened_tail_json}") + _must(bool(opened_tail_json.get("surface_id")), f"Expected trailing --id-format both to preserve surface_id: {opened_tail_json}") + _must("--json" not in str(opened_tail_json.get("url") or ""), f"Trailing output flags leaked into browser open URL: {opened_tail_json}") + _run_cli_json(cli, ["browser", tail_surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"]) + tail_url_payload = _run_cli_json(cli, ["browser", tail_surface, "url"]) + _must(str(tail_url_payload.get("url") or "").startswith(page_url), f"Expected trailing --json browser open to navigate: {tail_url_payload}") + + opened = _run_cli_json(cli, ["browser", "open", page_url]) + surface = str(opened.get("surface_ref") or opened.get("surface_id") or "") + _must(bool(surface), f"browser open returned no surface handle: {opened}") + _must(surface.startswith("surface:"), f"Expected short surface ref from browser open, got: {opened}") + + _run_cli_json(cli, ["browser", surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"]) + snapshot_text = _run_cli_text(cli, ["browser", surface, "snapshot", "--interactive"]) + _must("ref=e" in snapshot_text, f"Expected snapshot text with refs from CLI: {snapshot_text!r}") + + blank_opened = _run_cli_json(cli, ["browser", "open", "about:blank", "--workspace", workspace]) + blank_surface = str(blank_opened.get("surface_ref") or blank_opened.get("surface_id") or "") + _must(bool(blank_surface), f"Expected about:blank browser open to return a surface: {blank_opened}") + blank_snapshot = _run_cli_text(cli, ["browser", blank_surface, "snapshot", "--interactive"]) + _must("about:blank" in blank_snapshot and "get url" in blank_snapshot, f"Expected empty snapshot diagnostics for about:blank: {blank_snapshot!r}") opened_routed = _run_cli_json(cli, ["browser", "open", page_url, "--workspace", workspace]) routed_surface = str(opened_routed.get("surface_ref") or opened_routed.get("surface_id") or "") @@ -173,6 +218,14 @@ def main() -> int: _must(routed_url.startswith(page_url), f"Expected routed URL to start with page URL, got: {routed_url_payload}") _must("--workspace" not in routed_url and "--window" not in routed_url, f"Routing flags leaked into URL: {routed_url_payload}") + goto_url = f"{page_url}?goto=1" + goto_payload = _run_cli_json(cli, ["browser", surface, "goto", goto_url, "--snapshot-after"]) + _must(bool(goto_payload.get("post_action_snapshot")), f"Expected goto --snapshot-after to include post_action_snapshot: {goto_payload}") + goto_url_payload = _run_cli_json(cli, ["browser", surface, "url"]) + current_goto_url = str(goto_url_payload.get("url") or "") + _must(current_goto_url.startswith(goto_url), f"Expected goto --snapshot-after current URL to match target URL: {goto_url_payload}") + _must("--snapshot-after" not in current_goto_url, f"Expected goto URL to exclude trailing flag text: {goto_url_payload}") + find_text = _run_cli_json(cli, ["browser", surface, "find", "text", "row-b"]) _must(str(find_text.get("element_ref") or "").startswith("@e"), f"Expected element_ref from find text: {find_text}") diff --git a/tests_v2/test_browser_cli_wait_and_screenshot.py b/tests_v2/test_browser_cli_wait_and_screenshot.py new file mode 100644 index 00000000..fb4d2fb7 --- /dev/null +++ b/tests_v2/test_browser_cli_wait_and_screenshot.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Regression: browser wait/snapshot and screenshot CLI return usable file locations.""" + +import glob +import json +import os +import subprocess +import sys +import tempfile +import urllib.parse +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser( + "~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux" + ) + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob( + os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), + recursive=True, + ) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, *args: str) -> subprocess.CompletedProcess[str]: + cmd = [cli, "--socket", SOCKET_PATH, *args] + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc + + +def main() -> int: + cli = _find_cli_binary() + + with cmux(SOCKET_PATH) as c: + opened = c._call("browser.open_split", {"url": "about:blank"}) or {} + target = str(opened.get("surface_id") or opened.get("surface_ref") or "") + _must(target != "", f"browser.open_split returned no surface handle: {opened}") + + html = """ + + + cmux-browser-cli-regression + +
+

browser cli regression

+

ready

+
+ + +""".strip() + data_url = "data:text/html;charset=utf-8," + urllib.parse.quote(html) + c._call("browser.navigate", {"surface_id": target, "url": data_url}) + + wait_proc = _run_cli( + cli, + "browser", + target, + "wait", + "--load-state", + "interactive", + "--timeout-ms", + "5000", + ) + _must(wait_proc.stdout.strip() == "OK", f"Expected browser wait OK output: {wait_proc.stdout!r}") + + snapshot_payload = c._call("browser.snapshot", {"surface_id": target}) or {} + refs = snapshot_payload.get("refs") or {} + _must(isinstance(refs, dict) and len(refs) > 0, f"Expected snapshot refs for ref-based wait coverage: {snapshot_payload}") + ref_selector = str(next(iter(refs.keys()))) + ref_wait_proc = _run_cli( + cli, + "browser", + target, + "wait", + "--selector", + ref_selector, + "--timeout-ms", + "2000", + ) + _must(ref_wait_proc.stdout.strip() == "OK", f"Expected browser wait to resolve snapshot refs: {ref_wait_proc.stdout!r}") + + snapshot_proc = _run_cli(cli, "browser", target, "snapshot", "--compact") + _must( + snapshot_proc.stdout.strip().startswith("- document"), + f"Expected snapshot command to succeed with structured output: {snapshot_proc.stdout!r}", + ) + + screenshot_json_proc = _run_cli(cli, "browser", target, "screenshot", "--json") + screenshot_json_text = screenshot_json_proc.stdout.strip() + payload = json.loads(screenshot_json_text or "{}") + + _must("\\/" not in screenshot_json_text, f"Expected screenshot JSON without escaped slashes: {screenshot_json_text!r}") + _must("png_base64" not in payload, f"Expected screenshot JSON to omit png_base64 when file location is available: {payload}") + + screenshot_path = str(payload.get("path") or "") + screenshot_url = str(payload.get("url") or "") + _must(screenshot_path.startswith("/"), f"Expected screenshot path in JSON payload: {payload}") + _must(screenshot_url.startswith("file://"), f"Expected screenshot file URL in JSON payload: {payload}") + _must(Path(screenshot_path).is_file(), f"Expected screenshot file to exist: {payload}") + + out_dir = Path(tempfile.mkdtemp(prefix="cmux-browser-screenshot-cli-")) / "nested" / "dir" + out_path = out_dir / "capture.png" + screenshot_out_proc = _run_cli( + cli, + "browser", + target, + "screenshot", + "--out", + str(out_path), + ) + _must(screenshot_out_proc.stdout.strip() == f"OK {out_path}", f"Expected --out to print the requested path: {screenshot_out_proc.stdout!r}") + _must("file://" not in screenshot_out_proc.stdout, f"Expected --out to print a path, not a file URL: {screenshot_out_proc.stdout!r}") + _must(out_path.is_file(), f"Expected --out screenshot file to exist: {out_path}") + + print("PASS: browser CLI wait/snapshot and screenshot output work end-to-end") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_browser_devtools_visibility_stability.py b/tests_v2/test_browser_devtools_visibility_stability.py new file mode 100644 index 00000000..01ca9e32 --- /dev/null +++ b/tests_v2/test_browser_devtools_visibility_stability.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""v2 regression: browser DevTools stays open after a single toggle.""" + +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 _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_until(pred, timeout_s: float, label: str) -> None: + deadline = time.time() + timeout_s + last_exc = None + while time.time() < deadline: + try: + if pred(): + return + except Exception as exc: # noqa: BLE001 + last_exc = exc + time.sleep(0.05) + if last_exc is not None: + raise cmuxError(f"Timed out waiting for {label}: {last_exc}") + raise cmuxError(f"Timed out waiting for {label}") + + +def _surface_row(c: cmux, workspace_id: str, surface_id: str) -> dict: + 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 row + raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}") + + +def _devtools_visible(c: cmux, workspace_id: str, surface_id: str) -> bool: + row = _surface_row(c, workspace_id, surface_id) + return bool(row.get("developer_tools_visible")) + + +def _focus_browser_webview(c: cmux, surface_id: str, timeout_s: float = 2.0) -> None: + deadline = time.time() + timeout_s + last_exc = None + while time.time() < deadline: + try: + c.focus_surface(surface_id) + c.focus_webview(surface_id) + if c.is_webview_focused(surface_id): + return + except Exception as exc: # noqa: BLE001 + last_exc = exc + time.sleep(0.05) + raise cmuxError(f"Timed out waiting for browser webview focus: {last_exc}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + workspace_id = c.new_workspace() + try: + c.select_workspace(workspace_id) + time.sleep(0.3) + + surface_id = c.new_surface(panel_type="browser", url="https://example.com") + _wait_until( + lambda: _surface_row(c, workspace_id, surface_id).get("type") == "browser", + timeout_s=5.0, + label="browser surface in surface.list", + ) + _focus_browser_webview(c, surface_id, timeout_s=3.0) + + _must( + _devtools_visible(c, workspace_id, surface_id) is False, + "Expected DevTools to start closed", + ) + + c.simulate_shortcut("cmd+opt+i") + + _wait_until( + lambda: _devtools_visible(c, workspace_id, surface_id), + timeout_s=3.0, + label="DevTools visible after toggle", + ) + + deadline = time.time() + 1.5 + while time.time() < deadline: + _must( + _devtools_visible(c, workspace_id, surface_id) is True, + "DevTools reopened/closed unexpectedly after initial open", + ) + time.sleep(0.05) + finally: + try: + c.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: browser DevTools stays open after a single toggle") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_browser_file_url_load.py b/tests_v2/test_browser_file_url_load.py new file mode 100644 index 00000000..a4c63110 --- /dev/null +++ b/tests_v2/test_browser_file_url_load.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""v2 regression: browser can render local file:// HTML pages.""" + +import os +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 _wait_until(pred, timeout_s: float, label: str) -> None: + deadline = time.time() + timeout_s + last_exc = None + while time.time() < deadline: + try: + if pred(): + return + except Exception as exc: # noqa: BLE001 + last_exc = exc + time.sleep(0.05) + if last_exc is not None: + raise cmuxError(f"Timed out waiting for {label}: {last_exc}") + raise cmuxError(f"Timed out waiting for {label}") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="cmux-file-url-") as root: + html_path = Path(root) / "local-test.html" + html_path.write_text( + """ + + + cmux file url load + +

local HTML file loaded

+

This page is loaded via file://

+ + +""".strip(), + encoding="utf-8", + ) + file_url = html_path.resolve().as_uri() + + with cmux(SOCKET_PATH) as c: + opened = c._call("browser.open_split", {"url": "about:blank"}) or {} + sid = str(opened.get("surface_id") or "") + _must(bool(sid), f"browser.open_split returned no surface_id: {opened}") + + c._call("browser.navigate", {"surface_id": sid, "url": file_url}) + + _wait_until( + lambda: str((c._call("browser.get.title", {"surface_id": sid}) or {}).get("title") or "") + == "cmux file url load", + timeout_s=5.0, + label="browser.get.title(file://)", + ) + + page_text = c._call( + "browser.eval", + { + "surface_id": sid, + "script": "document.body ? (document.body.innerText || '') : ''", + }, + ) or {} + _must( + "local HTML file loaded" in str(page_text.get("value") or ""), + f"Expected file:// page body text: {page_text}", + ) + + url_payload = c._call("browser.url.get", {"surface_id": sid}) or {} + actual_url = str(url_payload.get("url") or "") + _must( + actual_url.startswith("file://"), + f"Expected browser.url.get to stay on file:// URL: {url_payload}", + ) + + print("PASS: browser loads local file:// HTML") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_cli_new_workspace_background_metadata.py b/tests_v2/test_cli_new_workspace_background_metadata.py new file mode 100644 index 00000000..845b9a1a --- /dev/null +++ b/tests_v2/test_cli_new_workspace_background_metadata.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Regression: CLI `new-workspace --cwd` should preload sidebar metadata without focus.""" + +from __future__ import annotations + +import glob +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +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 or "").strip() + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + parsed: dict[str, str] = {} + for raw in text.splitlines(): + line = raw.strip() + if not line or "=" not in line: + continue + key, value = line.split("=", 1) + parsed[key.strip()] = value.strip() + return parsed + + +def _wait_for_sidebar_git_branch(cli: str, workspace: str, timeout: float = 15.0) -> dict[str, str]: + deadline = time.time() + timeout + last_state = "" + + while time.time() < deadline: + state_text = _run_cli(cli, ["sidebar-state", "--workspace", workspace]) + last_state = state_text + state = _parse_sidebar_state(state_text) + raw_branch = state.get("git_branch", "") + branch = raw_branch.split(" ", 1)[0] + if branch and branch != "none": + return state + time.sleep(0.1) + + raise cmuxError( + "Timed out waiting for background git metadata on new workspace. " + f"Last sidebar-state: {last_state!r}" + ) + + +def _create_git_repo(root: Path) -> tuple[Path, str]: + repo = root / "repo" + repo.mkdir(parents=True, exist_ok=True) + + subprocess.run( + ["git", "-c", "init.defaultBranch=main", "init"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["git", "config", "user.name", "cmux-test"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["git", "config", "user.email", "cmux-test@example.com"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + (repo / "README.md").write_text("issue 915\n", encoding="utf-8") + subprocess.run( + ["git", "add", "README.md"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["git", "-c", "commit.gpgsign=false", "commit", "-m", "init"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=repo, + text=True, + ).strip() + return repo, branch + + +def main() -> int: + cli = _find_cli_binary() + temp_root = Path(tempfile.mkdtemp(prefix="cmux_issue_915_")) + created_workspace: str | None = None + + try: + repo_path, expected_branch = _create_git_repo(temp_root) + + with cmux(SOCKET_PATH) as c: + baseline_workspace = c.current_workspace() + + created = _run_cli(cli, ["new-workspace", "--cwd", str(repo_path)]) + _must(created.startswith("OK "), f"new-workspace expected OK response, got: {created!r}") + created_workspace = created.removeprefix("OK ").strip() + _must(bool(created_workspace), f"new-workspace returned no workspace handle: {created!r}") + + _must( + c.current_workspace() == baseline_workspace, + "new-workspace --cwd should preserve selected workspace", + ) + + sidebar_state = _wait_for_sidebar_git_branch(cli, created_workspace) + _must( + sidebar_state.get("cwd", "") == str(repo_path), + f"Expected sidebar cwd={repo_path!r}, got {sidebar_state.get('cwd', '')!r}", + ) + + raw_branch = sidebar_state.get("git_branch", "") + observed_branch = raw_branch.split(" ", 1)[0] + _must( + observed_branch == expected_branch, + f"Expected sidebar git branch {expected_branch!r}, got {raw_branch!r}", + ) + + _must( + c.current_workspace() == baseline_workspace, + "background metadata load should not switch selected workspace", + ) + finally: + if created_workspace: + try: + _run_cli(cli, ["close-workspace", "--workspace", created_workspace]) + except Exception: + pass + shutil.rmtree(temp_root, ignore_errors=True) + + print("PASS: new-workspace --cwd preloads sidebar metadata without focus") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py b/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py new file mode 100644 index 00000000..9e83ee0f --- /dev/null +++ b/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Regression: background workspaces should refresh git branch after external repo changes.""" + +from __future__ import annotations + +import glob +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +def _resolve_socket_path() -> str: + socket_path = os.environ.get("CMUX_SOCKET", "").strip() + if not socket_path: + raise cmuxError("CMUX_SOCKET is required (expected /tmp/cmux-debug-.sock)") + if not re.fullmatch(r"/tmp/cmux-debug-[^/]+\.sock", socket_path): + raise cmuxError(f"CMUX_SOCKET must be a tagged debug socket, got: {socket_path!r}") + return socket_path + + +SOCKET_PATH = _resolve_socket_path() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob( + os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), + recursive=True, + ) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str]) -> str: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return (proc.stdout or "").strip() + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + parsed: dict[str, str] = {} + for raw in text.splitlines(): + line = raw.strip() + if not line or "=" not in line: + continue + key, value = line.split("=", 1) + parsed[key.strip()] = value.strip() + return parsed + + +def _wait_for_sidebar_branch( + cli: str, + workspace: str, + expected_branch: str, + timeout: float = 15.0, +) -> dict[str, str]: + deadline = time.time() + timeout + last_state = "" + + while time.time() < deadline: + state_text = _run_cli(cli, ["sidebar-state", "--workspace", workspace]) + last_state = state_text + state = _parse_sidebar_state(state_text) + raw_branch = state.get("git_branch", "") + observed_branch = raw_branch.split(" ", 1)[0] + if observed_branch == expected_branch: + return state + time.sleep(0.1) + + raise cmuxError( + f"Timed out waiting for branch {expected_branch!r} on workspace {workspace}. " + f"Last sidebar-state: {last_state!r}" + ) + + +def _create_git_repo(root: Path) -> Path: + repo = root / "repo" + repo.mkdir(parents=True, exist_ok=True) + + subprocess.run( + ["git", "-c", "init.defaultBranch=main", "init"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["git", "config", "user.name", "cmux-test"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["git", "config", "user.email", "cmux-test@example.com"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + (repo / "README.md").write_text("issue 915 external refresh\n", encoding="utf-8") + subprocess.run( + ["git", "add", "README.md"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["git", "-c", "commit.gpgsign=false", "commit", "-m", "init"], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return repo + + +def main() -> int: + cli = _find_cli_binary() + temp_root = Path(tempfile.mkdtemp(prefix="cmux_issue_915_external_git_")) + created_workspace: str | None = None + + try: + repo_path = _create_git_repo(temp_root) + + with cmux(SOCKET_PATH) as client: + baseline_workspace = client.current_workspace() + + created = _run_cli(cli, ["new-workspace", "--cwd", str(repo_path)]) + _must(created.startswith("OK "), f"new-workspace expected OK response, got: {created!r}") + created_workspace = created.removeprefix("OK ").strip() + _must(bool(created_workspace), f"new-workspace returned no workspace handle: {created!r}") + + _must( + client.current_workspace() == baseline_workspace, + "new-workspace --cwd should preserve selected workspace", + ) + + initial_state = _wait_for_sidebar_branch(cli, created_workspace, "main") + _must( + initial_state.get("cwd", "") == str(repo_path), + f"Expected sidebar cwd={repo_path!r}, got {initial_state.get('cwd', '')!r}", + ) + + subprocess.run( + ["git", "checkout", "-b", "feature/external-refresh"], + cwd=repo_path, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + refreshed_state = _wait_for_sidebar_branch( + cli, + created_workspace, + "feature/external-refresh", + timeout=15.0, + ) + _must( + refreshed_state.get("cwd", "") == str(repo_path), + f"Expected refreshed sidebar cwd={repo_path!r}, got {refreshed_state.get('cwd', '')!r}", + ) + + _must( + client.current_workspace() == baseline_workspace, + "external git branch refresh should not switch selected workspace", + ) + finally: + if created_workspace: + try: + _run_cli(cli, ["close-workspace", "--workspace", created_workspace]) + except Exception: + pass + shutil.rmtree(temp_root, ignore_errors=True) + + print("PASS: background workspace git branch refreshes after external repo checkout") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_backspace_go_back.py b/tests_v2/test_command_palette_backspace_go_back.py new file mode 100644 index 00000000..7b152daa --- /dev/null +++ b/tests_v2/test_command_palette_backspace_go_back.py @@ -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()) diff --git a/tests_v2/test_command_palette_focus.py b/tests_v2/test_command_palette_focus.py new file mode 100644 index 00000000..859de7b8 --- /dev/null +++ b/tests_v2/test_command_palette_focus.py @@ -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()) diff --git a/tests_v2/test_command_palette_focus_lock_workspace_spawn.py b/tests_v2/test_command_palette_focus_lock_workspace_spawn.py new file mode 100644 index 00000000..d859b912 --- /dev/null +++ b/tests_v2/test_command_palette_focus_lock_workspace_spawn.py @@ -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()) diff --git a/tests_v2/test_command_palette_fuzzy_ranking.py b/tests_v2/test_command_palette_fuzzy_ranking.py new file mode 100644 index 00000000..8d6e30b2 --- /dev/null +++ b/tests_v2/test_command_palette_fuzzy_ranking.py @@ -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()) diff --git a/tests_v2/test_command_palette_modes.py b/tests_v2/test_command_palette_modes.py new file mode 100644 index 00000000..482e1c45 --- /dev/null +++ b/tests_v2/test_command_palette_modes.py @@ -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()) diff --git a/tests_v2/test_command_palette_navigation_keys.py b/tests_v2/test_command_palette_navigation_keys.py new file mode 100644 index 00000000..6a3d4b2a --- /dev/null +++ b/tests_v2/test_command_palette_navigation_keys.py @@ -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()) diff --git a/tests_v2/test_command_palette_rename_enter.py b/tests_v2/test_command_palette_rename_enter.py new file mode 100644 index 00000000..749e0ac0 --- /dev/null +++ b/tests_v2/test_command_palette_rename_enter.py @@ -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()) diff --git a/tests_v2/test_command_palette_rename_select_all.py b/tests_v2/test_command_palette_rename_select_all.py new file mode 100644 index 00000000..0b05ab4a --- /dev/null +++ b/tests_v2/test_command_palette_rename_select_all.py @@ -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()) diff --git a/tests_v2/test_command_palette_search_action_sync.py b/tests_v2/test_command_palette_search_action_sync.py new file mode 100644 index 00000000..533cb7e3 --- /dev/null +++ b/tests_v2/test_command_palette_search_action_sync.py @@ -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()) diff --git a/tests_v2/test_command_palette_search_typing_stability.py b/tests_v2/test_command_palette_search_typing_stability.py new file mode 100644 index 00000000..09b34722 --- /dev/null +++ b/tests_v2/test_command_palette_search_typing_stability.py @@ -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()) diff --git a/tests_v2/test_command_palette_shortcut_hint_sync.py b/tests_v2/test_command_palette_shortcut_hint_sync.py new file mode 100644 index 00000000..c6acc01a --- /dev/null +++ b/tests_v2/test_command_palette_shortcut_hint_sync.py @@ -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()) diff --git a/tests_v2/test_command_palette_switcher_all_windows.py b/tests_v2/test_command_palette_switcher_all_windows.py new file mode 100644 index 00000000..b779d383 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_all_windows.py @@ -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()) diff --git a/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py b/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py new file mode 100644 index 00000000..ba427506 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py @@ -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()) diff --git a/tests_v2/test_command_palette_switcher_renamed_surface.py b/tests_v2/test_command_palette_switcher_renamed_surface.py new file mode 100644 index 00000000..99b2fce0 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_renamed_surface.py @@ -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()) diff --git a/tests_v2/test_command_palette_switcher_surface_precedence.py b/tests_v2/test_command_palette_switcher_surface_precedence.py new file mode 100644 index 00000000..ec3850f5 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_surface_precedence.py @@ -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()) diff --git a/tests_v2/test_command_palette_switcher_type_labels.py b/tests_v2/test_command_palette_switcher_type_labels.py new file mode 100644 index 00000000..dbbe2fcd --- /dev/null +++ b/tests_v2/test_command_palette_switcher_type_labels.py @@ -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()) diff --git a/tests_v2/test_command_palette_window_scope.py b/tests_v2/test_command_palette_window_scope.py new file mode 100644 index 00000000..e6cfeab7 --- /dev/null +++ b/tests_v2/test_command_palette_window_scope.py @@ -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()) diff --git a/tests_v2/test_lint_swiftui_patterns.py b/tests_v2/test_lint_swiftui_patterns.py index f5d82c14..685480eb 100644 --- a/tests_v2/test_lint_swiftui_patterns.py +++ b/tests_v2/test_lint_swiftui_patterns.py @@ -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.*?)" + r"\.focused\(\$isCommandPaletteSearchFocused\)", + ), + ( + "rename input", + r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P.*?)" + 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 diff --git a/tests_v2/test_shortcut_window_scope.py b/tests_v2/test_shortcut_window_scope.py new file mode 100644 index 00000000..a13750e2 --- /dev/null +++ b/tests_v2/test_shortcut_window_scope.py @@ -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()) diff --git a/tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py b/tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py new file mode 100644 index 00000000..bc0cf9f3 --- /dev/null +++ b/tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py @@ -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()) diff --git a/tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py b/tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py new file mode 100644 index 00000000..b4413d83 --- /dev/null +++ b/tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py @@ -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()) diff --git a/tests_v2/test_split_cmd_shift_d_ctrl_d_no_portal_orphans.py b/tests_v2/test_split_cmd_shift_d_ctrl_d_no_portal_orphans.py new file mode 100644 index 00000000..7f5257e2 --- /dev/null +++ b/tests_v2/test_split_cmd_shift_d_ctrl_d_no_portal_orphans.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +""" +Regression: a Ctrl-D closed terminal must never become visible again before deinit. + +This targets the "ghost terminal" race: + 1) close starts (`surface.close.childExited`) + 2) panel is detached + 3) stale host callback re-binds the same surface + 4) it flips visible/active again (`ws.term.visible transition=0->1`) + 5) deinit only happens later + +Old behavior can pass steady-state orphan counts while still showing this transient bug. +""" + +import os +import re +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") +LOG_PATH_OVERRIDE = os.environ.get("CMUX_DEBUG_LOG") +ITERATIONS = int(os.environ.get("CMUX_PORTAL_ORPHAN_ITERS", "16")) +PANE_TIMEOUT_S = float(os.environ.get("CMUX_PORTAL_ORPHAN_PANE_TIMEOUT_S", "3.0")) +INTEGRITY_TIMEOUT_S = float(os.environ.get("CMUX_PORTAL_ORPHAN_INTEGRITY_TIMEOUT_S", "1.5")) +POLL_S = float(os.environ.get("CMUX_PORTAL_ORPHAN_POLL_S", "0.02")) +CTRL_D_RETRY_INTERVAL_S = float(os.environ.get("CMUX_PORTAL_ORPHAN_CTRL_D_RETRY_INTERVAL_S", "0.20")) +CTRL_D_MAX_EXTRA = int(os.environ.get("CMUX_PORTAL_ORPHAN_CTRL_D_MAX_EXTRA", "3")) +POST_CLOSE_SETTLE_S = float(os.environ.get("CMUX_PORTAL_ORPHAN_POST_CLOSE_SETTLE_S", "0.08")) +LOG_FLUSH_S = float(os.environ.get("CMUX_PORTAL_ORPHAN_LOG_FLUSH_S", "0.15")) + +RE_CLOSE = re.compile(r"surface\.close\.childExited .* surface=([0-9A-F]{5})\b") +RE_DEINIT_BEGIN = re.compile(r"surface\.lifecycle\.deinit\.begin surface=([0-9A-F]{5})\b") +RE_DEINIT_END = re.compile(r"surface\.lifecycle\.deinit\.end surface=([0-9A-F]{5})\b") +RE_VISIBLE_ON = re.compile(r"ws\.term\.visible .* surface=([0-9A-F]{5}) .* transition=0->1\b") + + +def _derive_log_path(socket_path: str) -> str: + if LOG_PATH_OVERRIDE: + return LOG_PATH_OVERRIDE + base = os.path.basename(socket_path) + if base.startswith("cmux-debug-") and base.endswith(".sock"): + slug = base[len("cmux-debug-") : -len(".sock")] + return f"/tmp/cmux-debug-{slug}.log" + return "/tmp/cmux-debug.log" + + +def _read_new_lines(log_path: str, offset: int) -> tuple[list[str], int]: + if not os.path.exists(log_path): + raise cmuxError(f"debug log not found at {log_path}") + with open(log_path, "rb") as f: + f.seek(offset) + data = f.read() + new_offset = f.tell() + if not data: + return [], new_offset + return data.decode("utf-8", errors="replace").splitlines(), new_offset + + +def _pane_count(layout_payload: dict) -> int: + return len((layout_payload.get("layout") or {}).get("panes") or []) + + +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 _pane_sort_key(pane: dict) -> tuple[float, float]: + frame = pane.get("frame") or {} + x = float(frame.get("x", 0.0)) + y = float(frame.get("y", 0.0)) + return (x, y) + + +def _panel_for_pane(layout_payload: dict, pane: dict) -> str: + pane_id = str(pane.get("paneId") or "") + 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 pane: pane_id={pane_id} selected={selected}") + return panel_id + + +def _rightmost_panel(layout_payload: dict) -> str: + panes = (layout_payload.get("layout") or {}).get("panes") or [] + if len(panes) < 2: + raise cmuxError(f"expected >=2 panes to find rightmost panel, got {len(panes)}") + rightmost = max(panes, key=_pane_sort_key) + return _panel_for_pane(layout_payload, rightmost) + + +def _bottom_right_panel(layout_payload: dict) -> str: + panes = (layout_payload.get("layout") or {}).get("panes") or [] + if len(panes) < 3: + raise cmuxError(f"expected >=3 panes to find bottom-right panel, got {len(panes)}") + bottom_right = max(panes, key=_pane_sort_key) + return _panel_for_pane(layout_payload, bottom_right) + + +def _wait_for_panes(c: cmux, target_panes: int, *, timeout_s: float, context: str) -> dict: + deadline = time.time() + timeout_s + last = None + while time.time() < deadline: + last = c.layout_debug() + if _pane_count(last) == target_panes: + return last + time.sleep(POLL_S) + raise cmuxError( + f"timed out waiting for {target_panes} panes ({context}); " + f"last_panes={_pane_count(last or {})} last_layout={last}" + ) + + +def _portal_stats(c: cmux, *, timeout_s: float) -> dict: + stats = c._call("debug.portal.stats", timeout_s=timeout_s) or {} + if not isinstance(stats, dict): + raise cmuxError(f"debug.portal.stats returned non-dict payload: {stats!r}") + return stats + + +def _portal_integrity_error(stats: dict) -> str | None: + totals = stats.get("totals") or {} + if not isinstance(totals, dict): + return f"portal totals payload is not a dict: {totals!r}" + + required_keys = ( + "orphan_terminal_subview_count", + "visible_orphan_terminal_subview_count", + "stale_entry_count", + ) + missing = [key for key in required_keys if key not in totals] + if missing: + return f"portal totals missing required counters: {', '.join(missing)}" + + try: + orphan = int(totals["orphan_terminal_subview_count"]) + visible_orphan = int(totals["visible_orphan_terminal_subview_count"]) + stale = int(totals["stale_entry_count"]) + except (TypeError, ValueError): + return ( + "portal totals contains non-integer counters " + f"(orphan={totals.get('orphan_terminal_subview_count')!r}, " + f"visible_orphan={totals.get('visible_orphan_terminal_subview_count')!r}, " + f"stale={totals.get('stale_entry_count')!r})" + ) + + if orphan != 0 or visible_orphan != 0 or stale != 0: + return ( + "portal totals show orphan/stale entries " + f"(orphan={orphan}, visible_orphan={visible_orphan}, stale={stale})" + ) + return None + + +def _wait_for_portal_integrity(c: cmux, *, timeout_s: float, context: str) -> None: + deadline = time.time() + timeout_s + last = None + error = None + while time.time() < deadline: + remaining = deadline - time.time() + if remaining <= 0: + break + last = _portal_stats(c, timeout_s=min(remaining, 0.5)) + error = _portal_integrity_error(last) + if error is None: + return + time.sleep(POLL_S) + raise cmuxError(f"{context}: {error}; stats={last}") + + +def _close_bottom_right_via_ctrl_d(c: cmux, *, bottom_right_panel_id: str, context: str) -> dict: + c.send_key_surface(bottom_right_panel_id, "ctrl-d") + next_retry_at = time.time() + CTRL_D_RETRY_INTERVAL_S + extra = 0 + deadline = time.time() + PANE_TIMEOUT_S + last = None + + while time.time() < deadline: + last = c.layout_debug() + if _pane_count(last) == 2: + return last + + if extra < CTRL_D_MAX_EXTRA and time.time() >= next_retry_at: + c.send_key_surface(bottom_right_panel_id, "ctrl-d") + extra += 1 + next_retry_at = time.time() + CTRL_D_RETRY_INTERVAL_S + time.sleep(POLL_S) + + raise cmuxError( + f"{context}: timed out collapsing back to 2 panes after ctrl-d " + f"(extra_ctrl_d={extra}, panel={bottom_right_panel_id}); last_layout={last}" + ) + + +def _find_close_rebind_violations(lines: list[str]) -> tuple[int, list[str]]: + close_pending: set[str] = set() + deinit_started: set[str] = set() + close_count = 0 + violations: list[str] = [] + + for line in lines: + m = RE_CLOSE.search(line) + if m: + sid = m.group(1) + close_pending.add(sid) + close_count += 1 + continue + + m = RE_DEINIT_BEGIN.search(line) + if m: + sid = m.group(1) + deinit_started.add(sid) + continue + + m = RE_DEINIT_END.search(line) + if m: + sid = m.group(1) + close_pending.discard(sid) + deinit_started.discard(sid) + continue + + m = RE_VISIBLE_ON.search(line) + if m: + sid = m.group(1) + if sid in close_pending: + violations.append(line) + + return close_count, violations + + +def main() -> int: + log_path = _derive_log_path(SOCKET_PATH) + if not os.path.exists(log_path): + raise cmuxError(f"debug log not found at {log_path} for socket={SOCKET_PATH}") + log_offset = os.path.getsize(log_path) + + with cmux(SOCKET_PATH) as c: + c.activate_app() + workspace_id = c.new_workspace() + c.select_workspace(workspace_id) + time.sleep(0.2) + + c.new_split("right") + layout = _wait_for_panes(c, 2, timeout_s=PANE_TIMEOUT_S, context="initial right split") + _wait_for_portal_integrity(c, timeout_s=INTEGRITY_TIMEOUT_S, context="after initial right split") + + for iteration in range(1, ITERATIONS + 1): + right_panel_id = _rightmost_panel(layout) + c.focus_surface_by_panel(right_panel_id) + c.new_split("down") + layout = _wait_for_panes( + c, + 3, + timeout_s=PANE_TIMEOUT_S, + context=f"iter={iteration} after split down", + ) + + bottom_right_panel_id = _bottom_right_panel(layout) + layout = _close_bottom_right_via_ctrl_d( + c, + bottom_right_panel_id=bottom_right_panel_id, + context=f"iter={iteration}", + ) + _wait_for_portal_integrity(c, timeout_s=INTEGRITY_TIMEOUT_S, context=f"iter={iteration} integrity") + if POST_CLOSE_SETTLE_S > 0: + time.sleep(POST_CLOSE_SETTLE_S) + + c.close_workspace(workspace_id) + + if LOG_FLUSH_S > 0: + time.sleep(LOG_FLUSH_S) + lines, _ = _read_new_lines(log_path, log_offset) + close_count, violations = _find_close_rebind_violations(lines) + if close_count == 0: + raise cmuxError("no surface.close.childExited events captured; test did not exercise close path") + if violations: + sample = "\n".join(violations[:5]) + raise cmuxError( + "detected close->visible rebind race (closed surface became visible before deinit):\n" + f"{sample}" + ) + + print( + "PASS: no close->visible rebind races during split-down + ctrl-d churn " + f"(iters={ITERATIONS}, closes={close_count})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/bonsplit b/vendor/bonsplit index cf929c88..fa452db1 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit cf929c887af79ea8b881e39da5b8c4ee1d6b9009 +Subproject commit fa452db181f361514087558a29204bda7e38218f diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 00000000..389f57d6 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,3 @@ +RESEND_API_KEY= +CMUX_FEEDBACK_FROM_EMAIL= +CMUX_FEEDBACK_RATE_LIMIT_ID= diff --git a/web/app/(legal)/eula/page.tsx b/web/app/[locale]/(legal)/eula/page.tsx similarity index 99% rename from web/app/(legal)/eula/page.tsx rename to web/app/[locale]/(legal)/eula/page.tsx index 114f32bb..85676b2d 100644 --- a/web/app/(legal)/eula/page.tsx +++ b/web/app/[locale]/(legal)/eula/page.tsx @@ -8,7 +8,7 @@ export const metadata: Metadata = { export default function EulaPage() { return ( <> -

End-User License Agreement

+

EULA

Last updated: December 2, 2025

diff --git a/web/app/(legal)/layout.tsx b/web/app/[locale]/(legal)/layout.tsx similarity index 100% rename from web/app/(legal)/layout.tsx rename to web/app/[locale]/(legal)/layout.tsx diff --git a/web/app/(legal)/privacy-policy/page.tsx b/web/app/[locale]/(legal)/privacy-policy/page.tsx similarity index 97% rename from web/app/(legal)/privacy-policy/page.tsx rename to web/app/[locale]/(legal)/privacy-policy/page.tsx index 982b07f6..fc945d36 100644 --- a/web/app/(legal)/privacy-policy/page.tsx +++ b/web/app/[locale]/(legal)/privacy-policy/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Link } from "../../../../i18n/navigation"; export const metadata: Metadata = { title: "Privacy Policy — cmux", @@ -29,7 +30,7 @@ export default function PrivacyPolicyPage() {

By using our Service, you accept this Privacy Policy and our{" "} - Terms of Service, and you consent to + Terms of Service, and you consent to our collection, storage, use and disclosure of your information as described here.

diff --git a/web/app/(legal)/terms-of-service/page.tsx b/web/app/[locale]/(legal)/terms-of-service/page.tsx similarity index 100% rename from web/app/(legal)/terms-of-service/page.tsx rename to web/app/[locale]/(legal)/terms-of-service/page.tsx diff --git a/web/app/assets/images.d.ts b/web/app/[locale]/assets/images.d.ts similarity index 100% rename from web/app/assets/images.d.ts rename to web/app/[locale]/assets/images.d.ts diff --git a/web/app/assets/landing-image.png b/web/app/[locale]/assets/landing-image.png similarity index 100% rename from web/app/assets/landing-image.png rename to web/app/[locale]/assets/landing-image.png diff --git a/web/app/[locale]/blog/cmd-shift-u/page.tsx b/web/app/[locale]/blog/cmd-shift-u/page.tsx new file mode 100644 index 00000000..815d4790 --- /dev/null +++ b/web/app/[locale]/blog/cmd-shift-u/page.tsx @@ -0,0 +1,74 @@ +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { Link } from "../../../../i18n/navigation"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "blog.cmdShiftU" }); + const url = locale === "en" ? "/blog/cmd-shift-u" : `/${locale}/blog/cmd-shift-u`; + return { + title: t("metaTitle"), + description: t("metaDescription"), + keywords: [ + "cmux", "terminal", "macOS", "notifications", "AI coding agents", + "keyboard shortcuts", "developer tools", "workflow", + ], + openGraph: { + title: t("metaTitle"), + description: t("metaDescription"), + type: "article", + publishedTime: "2026-03-04T00:00:00Z", + url, + }, + twitter: { + card: "summary", + title: t("metaTitle"), + description: t("metaDescription"), + }, + alternates: { canonical: url }, + }; +} + +export default function CmdShiftUPage() { + const t = useTranslations("blog.posts.cmdShiftU"); + const tc = useTranslations("common"); + + return ( + <> +
+ + ← {tc("backToBlog")} + +
+ +

{t("title")}

+ + +

{t("p1")}

+ +