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 index 0dc2871a..ec787452 100644 --- a/.github/workflows/build-ghosttykit.yml +++ b/.github/workflows/build-ghosttykit.yml @@ -8,12 +8,9 @@ on: jobs: build-ghosttykit: - # Never run self-hosted jobs for fork pull requests. + # 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: self-hosted - concurrency: - group: self-hosted-build - cancel-in-progress: false + runs-on: depot-macos-latest steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -72,7 +69,7 @@ jobs: exit 1 fi fi - cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast + 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' 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 145bc80e..7315d8ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Validate self-hosted runner guards + - name: Validate Depot runner guards run: ./tests/test_ci_self_hosted_guard.sh - name: Validate create-dmg version pinning @@ -25,6 +25,9 @@ jobs: - 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: @@ -44,12 +47,7 @@ jobs: run: bun tsc --noEmit tests: - # Never run self-hosted jobs for fork pull requests. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: self-hosted - concurrency: - group: self-hosted-build - cancel-in-progress: false + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -59,51 +57,41 @@ jobs: - 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 + 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 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: | # 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" - rm -rf "$SOURCE_PACKAGES_DIR" mkdir -p "$SOURCE_PACKAGES_DIR" for attempt in 1 2 3; do @@ -138,12 +126,13 @@ jobs: EXIT_CODE=$? set -e - # SwiftPM binary artifact resolution can occasionally fail on self-hosted + # 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) @@ -162,12 +151,151 @@ jobs: fi fi + 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 + + - 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 | sort | tail -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: Download pre-built GhosttyKit.xcframework + run: | + ./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: | + 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 SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ - -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ - -disableAutomaticPackageResolution \ - -destination "platform=macOS" \ - -only-testing:cmuxUITests/UpdatePillUITests test + # 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 052e67e3..f645b45c 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,6 +11,10 @@ on: default: false type: boolean +concurrency: + group: nightly-build-${{ github.ref_name }} + cancel-in-progress: true + permissions: contents: write @@ -25,6 +28,7 @@ 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 @@ -35,58 +39,67 @@ jobs: 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 + - name: Checkout build ref uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.decide.outputs.head_sha }} @@ -113,30 +126,18 @@ jobs: - name: Install build deps run: | - brew update - brew install zig 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: @@ -150,38 +151,53 @@ 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: Inject nightly identities and metadata 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. @@ -191,23 +207,49 @@ 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 @@ -233,7 +275,7 @@ 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 env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | @@ -241,16 +283,20 @@ 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 env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} @@ -261,41 +307,62 @@ 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" + + 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 env: @@ -308,9 +375,11 @@ jobs: exit 0 fi brew install getsentry/tools/sentry-cli || true - sentry-cli debug-files upload --include-sources build/Build/Products/Release/ + sentry-cli debug-files upload --include-sources \ + build-arm/Build/Products/Release/ \ + build-universal/Build/Products/Release/ - - name: Generate Sparkle appcast (nightly) + - name: Generate Sparkle appcasts (nightly) env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | @@ -319,8 +388,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' run: | set -euo pipefail git config user.name "github-actions[bot]" @@ -329,6 +412,7 @@ jobs: git push origin refs/tags/nightly --force - name: Publish nightly release assets + if: needs.decide.outputs.should_publish == 'true' uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: nightly @@ -338,13 +422,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 @@ -352,12 +442,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 7adf546a..ec935c63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,10 +14,7 @@ env: 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -102,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@${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' @@ -150,7 +130,9 @@ 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: Inject Sparkle keys into Info.plist if: steps.guard_release_assets.outputs.skip_all != 'true' 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 d92de590..967cb442 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -94,6 +94,7 @@ jobs: depends_on macos: ">= :sonoma" app "cmux.app" + binary "#{appdir}/cmux.app/Contents/Resources/bin/cmux" zap trash: [ "~/Library/Application Support/cmux", 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 a8eeae97..7662a1ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,103 @@ All notable changes to cmux are documented here. +## [0.62.0] - 2026-03-07 + +### 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)) + +### 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)) + +### 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)) + +### Thanks to 21 contributors! +- [@afxjzs](https://github.com/afxjzs) +- [@AI-per](https://github.com/AI-per) +- [@atani](https://github.com/atani) +- [@austinywang](https://github.com/austinywang) +- [@cheulyop](https://github.com/cheulyop) +- [@ConnorCallison](https://github.com/ConnorCallison) +- [@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 diff --git a/CLAUDE.md b/CLAUDE.md index 7ec459ba..9e22303e 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: @@ -91,12 +115,31 @@ 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. - **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 @@ -114,21 +157,14 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents). - All non-focus commands should preserve current user focus context while still applying data/model changes. -## E2E mac UI tests +## Testing policy -Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`: +**Never run tests locally.** All tests (E2E, UI, python socket tests) run via GitHub Actions or on the VM. -```bash -ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test' -``` - -## Basic tests - -Run basic automated tests on the UTM macOS VM (never on the host machine): - -```bash -ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmux DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit) && open "$APP" --env CMUX_SOCKET_MODE=allowAll && for i in {1..20}; do [ -S /tmp/cmux-debug.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py' -``` +- **E2E / UI tests:** trigger via `gh workflow run test-e2e.yml` (see cmuxterm-hq CLAUDE.md for details) +- **Unit tests:** `xcodebuild -scheme cmux-unit` is safe (no app launch), but prefer CI +- **Python socket tests (tests_v2/):** these connect to a running cmux instance's socket. Never launch an untagged `cmux DEV.app` to run them. If you must test locally, use a tagged build's socket (`/tmp/cmux-debug-.sock`) with `CMUX_SOCKET=/tmp/cmux-debug-.sock` +- **Never `open` an untagged `cmux DEV.app`** from DerivedData. It conflicts with the user's running debug instance. ## Ghostty submodule workflow diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8dc13908..249707ce 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -539,9 +539,159 @@ private enum SocketPasswordResolver { } } +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 @@ -564,40 +714,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() { @@ -753,8 +931,19 @@ struct CMUXCLI { } func run() throws { - let environment = ProcessInfo.processInfo.environment - var socketPath = Self.defaultSocketPath(environment: environment) + let processEnv = ProcessInfo.processInfo.environment + let envSocketPath: String? = { + guard let raw = processEnv["CMUX_SOCKET_PATH"] else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }() + 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 @@ -768,6 +957,7 @@ struct CMUXCLI { throw CLIError(message: "--socket requires a path") } socketPath = args[index + 1] + socketPathSource = .explicitFlag index += 2 continue } @@ -822,7 +1012,12 @@ struct CMUXCLI { command: command, commandArgs: commandArgs, socketPath: socketPath, - processEnv: ProcessInfo.processInfo.environment + processEnv: processEnv + ) + let resolvedSocketPath = CLISocketPathResolver.resolve( + requestedPath: socketPath, + source: socketPathSource, + environment: processEnv ) if command == "version" { @@ -830,6 +1025,12 @@ struct CMUXCLI { 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") { @@ -840,16 +1041,28 @@ struct CMUXCLI { return } - let client = SocketClient(path: socketPath) + 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] + data: [ + "command": command, + "path": resolvedSocketPath + ] ) do { try client.connect() - cliTelemetry.breadcrumb("socket.connect.success") + cliTelemetry.breadcrumb("socket.connect.success", data: ["path": resolvedSocketPath]) } catch { - cliTelemetry.breadcrumb("socket.connect.failure") + cliTelemetry.breadcrumb("socket.connect.failure", data: ["path": resolvedSocketPath]) cliTelemetry.captureError(stage: "socket_connect", error: error) throw error } @@ -1026,22 +1239,25 @@ struct CMUXCLI { try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) 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": @@ -1447,7 +1663,16 @@ 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": @@ -1541,12 +1766,243 @@ 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 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:") { + throw CLIError(message: response) + } + return response + } private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { _ = jsonOutput if let parsed = try CLIIDFormat.parse(raw) { @@ -2558,7 +3014,34 @@ fi 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 @@ -2587,8 +3070,8 @@ fi } 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) @@ -2598,6 +3081,29 @@ fi } } + 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, @@ -2675,6 +3181,17 @@ fi 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. @@ -2700,14 +3217,17 @@ fi 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 @@ -2715,12 +3235,17 @@ fi 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) @@ -2747,8 +3272,8 @@ fi 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) ?? "") } @@ -2765,8 +3290,8 @@ fi 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") } @@ -2799,12 +3324,10 @@ fi } 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 } @@ -3006,17 +3529,139 @@ fi 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") } @@ -3074,8 +3719,8 @@ fi "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) @@ -3114,8 +3759,8 @@ fi 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 { @@ -4006,16 +4651,18 @@ fi """ case "new-workspace": return """ - Usage: cmux new-workspace [--command ] + 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 --command "npm test" + cmux new-workspace --cwd ~/projects/myapp + cmux new-workspace --cwd . --command "npm test" """ case "list-workspaces": return """ @@ -4723,7 +5370,7 @@ fi """ case "claude-hook": return """ - Usage: cmux claude-hook [flags] + Usage: cmux claude-hook [flags] Hook for Claude Code integration. Reads JSON from stdin. @@ -4734,6 +5381,7 @@ fi 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 Target workspace (default: $CMUX_WORKSPACE_ID) @@ -4823,6 +5471,25 @@ fi 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 [options] + cmux markdown (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 Target workspace (default: $CMUX_WORKSPACE_ID) + --surface Source surface to split from (default: focused surface) + --window Target window + + Examples: + cmux markdown open plan.md + cmux markdown ~/project/CHANGELOG.md + cmux markdown open ./docs/design.md --workspace 0 + """ default: return nil } @@ -5448,8 +6115,10 @@ fi } 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 "{}" } @@ -5968,6 +6637,24 @@ fi 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) @@ -6386,15 +7073,12 @@ fi } 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() while true { let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") @@ -6486,30 +7170,36 @@ fi } 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 seen: Set = [] + 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() 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")) } let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") let repoInfo = current.appendingPathComponent("Resources/Info.plist") if fileManager.fileExists(atPath: projectMarker.path), fileManager.fileExists(atPath: repoInfo.path) { - candidates.append(repoInfo) + appendIfExisting(repoInfo) break } @@ -6520,30 +7210,31 @@ fi 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() ] 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 = [] - 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? { @@ -6561,12 +7252,27 @@ fi 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] [options] + cmux Open a directory in a new workspace (launches cmux if needed) + cmux [global-options] [options] Handle Inputs: For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes. @@ -6590,7 +7296,7 @@ fi reorder-workspace --workspace (--index | --before | --after ) [--window ] workspace-action --action [--workspace ] [--title ] list-workspaces - new-workspace [--command ] + new-workspace [--cwd ] [--command ] ssh [--name ] [--port <n>] [--identity <path>] [--ssh-option <opt>] [-- <remote-command-args>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] list-panes [--workspace <id|ref>] @@ -6648,6 +7354,8 @@ fi 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] @@ -6663,6 +7371,7 @@ fi 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> ... @@ -6687,9 +7396,8 @@ fi 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. - Debug CLI defaults: /tmp/cmux-last-socket-path -> /tmp/cmux-debug.sock. - Release CLI default: /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. CMUX_CLI_SENTRY_DISABLED Set to 1 to disable CLI Sentry socket diagnostics. """ diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index aa0221e6..a975a0e7 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -28,6 +28,9 @@ 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 */; }; @@ -38,6 +41,8 @@ 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 */; }; @@ -68,6 +73,7 @@ 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 */; }; @@ -84,6 +90,10 @@ 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 */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -165,12 +175,16 @@ 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>"; }; @@ -191,8 +205,10 @@ 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; }; @@ -215,7 +231,11 @@ F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; }; - /* End PBXFileReference section */ + 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>"; }; +/* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ A5001030 /* Frameworks */ = { @@ -226,6 +246,7 @@ A5001230 /* Sparkle in Frameworks */, A5001250 /* Sentry in Frameworks */, A5001270 /* PostHog in Frameworks */, + A5001290 /* MarkdownUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -261,6 +282,8 @@ 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -308,6 +331,7 @@ B9000003A1B2C3D4E5F60719 /* CLI */, 087C454FFF74443AB06942C3 /* Resources */, A5001101 /* Assets.xcassets */, + IC000002 /* AppIcon.icon */, A5001016 /* GhosttyKit.xcframework */, A5001017 /* ghostty.h */, A5001018 /* cmux-Bridging-Header.h */, @@ -344,11 +368,15 @@ 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 */, @@ -385,6 +413,8 @@ B2E7294509CC42FE9191870E /* xterm-ghostty */, A5002001 /* THIRD_PARTY_LICENSES.md */, C1ADE00001A1B2C3D4E5F719 /* claude */, + DA7A10CA710E000000000001 /* Localizable.xcstrings */, + DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, ); path = Resources; sourceTree = "<group>"; @@ -409,6 +439,7 @@ B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, + B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, @@ -428,6 +459,8 @@ F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, + A5008380 /* BrowserFindJavaScriptTests.swift */, + A5008382 /* CommandPaletteSearchEngineTests.swift */, ); path = cmuxTests; sourceTree = "<group>"; @@ -456,6 +489,7 @@ A5001251 /* Sentry */, A5001271 /* PostHog */, A5001261 /* Bonsplit */, + A5001291 /* MarkdownUI */, ); name = GhosttyTabs; productName = GhosttyTabs; @@ -534,12 +568,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 */; @@ -583,11 +635,15 @@ 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 */, @@ -619,6 +675,7 @@ B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.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 */, @@ -638,6 +695,8 @@ F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, + A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, + A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -725,6 +784,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"; @@ -738,7 +798,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -747,7 +807,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -777,7 +837,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -786,7 +846,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -800,7 +860,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="; @@ -842,6 +902,7 @@ 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; @@ -852,10 +913,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -869,11 +930,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.61.0; - ONLY_ACTIVE_ARCH = YES; + MARKETING_VERSION = 0.62.0; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -886,10 +947,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -905,11 +966,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.61.0; - ONLY_ACTIVE_ARCH = YES; + MARKETING_VERSION = 0.62.0; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -945,6 +1006,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; @@ -972,6 +1041,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/README.ko.md b/README.ko.md index 80ac5fde..f5ec7119 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,9 +1,9 @@ -> 이 번역은 Claude에 의해 생성되었습니다. 개선 사항이 있으면 PR을 제출해 주세요. +> 이 문서는 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> <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"> @@ -17,17 +17,17 @@ ## 기능 -- **세로 탭** — 사이드바에 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로 구동되어 부드러운 렌더링 제공 +- **세로 탭** — 사이드바에서 git 브랜치, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 한눈에 볼 수 있어요. +- **알림 링** — AI 에이전트(Claude Code, OpenCode)가 입력을 기다리면 패널에 파란색 링이 뜨고 탭이 강조돼요. +- **알림 패널** — 대기 중인 알림을 한곳에서 확인하고, 가장 최근 읽지 않은 알림으로 바로 이동할 수 있어요. +- **분할 패널** — 수평·수직 분할을 지원해요. +- **내장 브라우저** — [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅된 스크립팅 API를 갖춘 브라우저를 터미널 옆에 띄울 수 있어요. +- **스크립팅** — CLI와 socket API로 워크스페이스 생성, 패널 분할, 키 입력 전송, 브라우저 자동화가 가능해요. +- **네이티브 macOS 앱** — Electron이 아닌 Swift와 AppKit으로 만들었어요. 빠르게 실행되고 메모리도 적게 써요. +- **Ghostty 호환** — 기존 `~/.config/ghostty/config`에서 테마, 글꼴, 색상 설정을 그대로 읽어와요. +- **GPU 가속** — libghostty 기반이라 렌더링이 부드러워요. -## 설치 +## 설치하기 ### DMG (권장) @@ -35,7 +35,7 @@ <img src="./docs/assets/macos-badge.png" alt="macOS용 cmux 다운로드" width="180" /> </a> -`.dmg` 파일을 열고 cmux를 응용 프로그램 폴더로 드래그하세요. cmux는 Sparkle을 통해 자동 업데이트되므로, 한 번만 다운로드하면 됩니다. +`.dmg` 파일을 열고 cmux를 응용 프로그램 폴더로 드래그하면 돼요. Sparkle을 통해 자동 업데이트되니 한 번만 다운로드하면 돼요. ### Homebrew @@ -44,25 +44,25 @@ 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 브랜치, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 보여주는 세로 탭이 있어요. 알림 시스템은 터미널 시퀀스(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 열기까지요. ## 키보드 단축키 @@ -98,13 +98,13 @@ brew upgrade --cask cmux | ⌘ D | 오른쪽으로 분할 | | ⌘ ⇧ D | 아래로 분할 | | ⌥ ⌘ ← → ↑ ↓ | 방향키로 패널 포커스 이동 | -| ⌘ ⇧ H | 포커스된 패널 깜빡임 | +| ⌘ ⇧ H | 현재 패널 깜빡임 | ### 브라우저 | 단축키 | 동작 | |----------|--------| -| ⌘ ⇧ L | 분할에서 브라우저 열기 | +| ⌘ ⇧ L | 분할 패널로 브라우저 열기 | | ⌘ L | 주소창 포커스 | | ⌘ [ | 뒤로 | | ⌘ ] | 앞으로 | @@ -116,7 +116,7 @@ brew upgrade --cask cmux | 단축키 | 동작 | |----------|--------| | ⌘ I | 알림 패널 표시 | -| ⌘ ⇧ U | 최신 읽지 않은 알림으로 이동 | +| ⌘ ⇧ U | 최근 읽지 않은 알림으로 이동 | ### 찾기 @@ -125,7 +125,7 @@ brew upgrade --cask cmux | ⌘ F | 찾기 | | ⌘ G / ⌘ ⇧ G | 다음 찾기 / 이전 찾기 | | ⌘ ⇧ F | 찾기 바 숨기기 | -| ⌘ E | 선택 영역으로 찾기 | +| ⌘ E | 선택한 텍스트로 찾기 | ### 터미널 @@ -148,6 +148,6 @@ brew upgrade --cask cmux ## 라이선스 -이 프로젝트는 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 9093268d..93e896bc 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ The best developers have always built their own tools. Nobody has figured out th 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 @@ -233,10 +237,10 @@ cmux does **not** restore live process state inside terminal apps. For example, Ways to get involved: -- Follow us on X for updates [@manaflowai](https://x.com/manaflowai) or [@lawrencecchen](https://x.com/lawrencecchen) +- 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 me know what you're building with cmux +- Let us know what you're building with cmux ## Community @@ -245,6 +249,7 @@ Ways to get involved: - [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 diff --git a/Resources/Info.plist b/Resources/Info.plist index ba335119..00d9fa86 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -28,6 +28,24 @@ <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>NSServices</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..d568c9b3 --- /dev/null +++ b/Resources/Localizable.xcstrings @@ -0,0 +1,72724 @@ +{ + "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" + } + } + } + }, + "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.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.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.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.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.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.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.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 index 9c81ea54..203ba1db 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -105,6 +105,169 @@ is_http_url() { 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")" @@ -212,9 +375,10 @@ if [[ $# -eq 0 ]]; then fi # Scan for flags that indicate explicit user intent → pass through. -# Also collect non-flag arguments (potential URLs/files). +# Also collect non-flag arguments and route eligible browser targets to cmux. passthrough=false -urls=() +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) @@ -228,17 +392,33 @@ for arg in "$@"; do ;; *) if is_http_url "$arg"; then - urls+=("$arg") + 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 - # Non-URL, non-flag argument (file path, etc.) → pass through all - passthrough=true - break + passthrough_args+=("$arg") fi ;; esac done -if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then +if [[ "$passthrough" == true ]] || [[ ${#cmux_targets[@]} -eq 0 ]]; then system_open "$@" fi @@ -257,6 +437,7 @@ if [[ -n "$settings_domain" ]]; then if [[ -n "$whitelist_raw" ]]; then load_whitelist_patterns "$whitelist_raw" fi + fi # Find cmux CLI (same directory as this script). @@ -268,16 +449,18 @@ if [[ ! -x "$CMUX_CLI" ]]; then 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 "${urls[@]}"; do - if ! host_matches_whitelist "$url"; then +for url in "${cmux_targets[@]}"; do + if is_http_url "$url" && ! host_matches_whitelist "$url"; then failed_urls+=("$url") continue fi - "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") + CMUX_RESPECT_EXTERNAL_OPEN_RULES=1 "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") done -# Fall back to system open only for URLs that failed. -if [[ ${#failed_urls[@]} -gt 0 ]]; then - system_open "${failed_urls[@]}" +# 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/shell-integration/.zshenv b/Resources/shell-integration/.zshenv index 21570241..68925a2f 100644 --- a/Resources/shell-integration/.zshenv +++ b/Resources/shell-integration/.zshenv @@ -13,7 +13,9 @@ # - CMUX_ZSH_ZDOTDIR (set by cmux when it overwrote a user-provided ZDOTDIR) # - unset (zsh treats unset ZDOTDIR as $HOME) +builtin typeset _cmux_had_ghostty_zdotdir=0 if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then + _cmux_had_ghostty_zdotdir=1 builtin export ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" builtin unset GHOSTTY_ZSH_ZDOTDIR elif [[ -n "${CMUX_ZSH_ZDOTDIR+X}" ]]; then @@ -31,7 +33,9 @@ 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 + # Guard on GHOSTTY_ZSH_ZDOTDIR being set by Ghostty. When users configure + # shell-integration=none, Ghostty does not set this and we must skip. + if [[ "$_cmux_had_ghostty_zdotdir" == "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 @@ -43,5 +47,5 @@ fi fi fi - builtin unset _cmux_file _cmux_ghostty _cmux_integ + builtin unset _cmux_file _cmux_ghostty _cmux_integ _cmux_had_ghostty_zdotdir } diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 85027ee4..643fc841 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -41,6 +41,9 @@ _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" _CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}" _CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}" _CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}" +_CMUX_GIT_HEAD_LAST_PWD="${_CMUX_GIT_HEAD_LAST_PWD:-}" +_CMUX_GIT_HEAD_PATH="${_CMUX_GIT_HEAD_PATH:-}" +_CMUX_GIT_HEAD_SIGNATURE="${_CMUX_GIT_HEAD_SIGNATURE:-}" _CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}" _CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}" _CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}" @@ -51,6 +54,41 @@ _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" +_cmux_git_resolve_head_path() { + # Resolve the HEAD file path without invoking git (fast; works for worktrees). + local dir="$PWD" + while :; do + if [[ -d "$dir/.git" ]]; then + printf '%s\n' "$dir/.git/HEAD" + return 0 + fi + if [[ -f "$dir/.git" ]]; then + local line gitdir + IFS= read -r line < "$dir/.git" || line="" + if [[ "$line" == gitdir:* ]]; then + gitdir="${line#gitdir:}" + gitdir="${gitdir## }" + gitdir="${gitdir%% }" + [[ -n "$gitdir" ]] || return 1 + [[ "$gitdir" != /* ]] && gitdir="$dir/$gitdir" + printf '%s\n' "$gitdir/HEAD" + return 0 + fi + fi + [[ "$dir" == "/" || -z "$dir" ]] && break + dir="$(dirname "$dir")" + done + return 1 +} + +_cmux_git_head_signature() { + local head_path="$1" + [[ -n "$head_path" && -r "$head_path" ]] || return 1 + local line + IFS= read -r line < "$head_path" || return 1 + printf '%s\n' "$line" +} + _cmux_report_tty_once() { # Send the TTY name to the app once per session so the batched port scanner # knows which TTY belongs to this panel. @@ -62,7 +100,7 @@ _cmux_report_tty_once() { _CMUX_TTY_REPORTED=1 { _cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - } >/dev/null 2>&1 & + } >/dev/null 2>&1 & disown } _cmux_ports_kick() { @@ -74,7 +112,7 @@ _cmux_ports_kick() { _CMUX_PORTS_LAST_RUN=$SECONDS { _cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - } >/dev/null 2>&1 & + } >/dev/null 2>&1 & disown } _cmux_prompt_command() { @@ -123,7 +161,26 @@ _cmux_prompt_command() { { local qpwd="${pwd//\"/\\\"}" _cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - } >/dev/null 2>&1 & + } >/dev/null 2>&1 & disown + fi + + # Branch can change via aliases/tools while an older probe is still in flight. + # Track .git/HEAD content so we can restart stale probes immediately. + local git_head_changed=0 + if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then + _CMUX_GIT_HEAD_LAST_PWD="$pwd" + _CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)" + _CMUX_GIT_HEAD_SIGNATURE="" + fi + if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then + local head_signature + head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" + if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # Also invalidate the PR probe so it refreshes with the new branch. + _CMUX_PR_LAST_RUN=0 + fi fi # Git branch/dirty can change without a directory change (e.g. `git checkout`), @@ -131,7 +188,7 @@ _cmux_prompt_command() { # When pwd changes (cd into a different repo), kill the old probe and start fresh # so the sidebar picks up the new branch immediately. if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then - if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then + if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" || "$git_head_changed" == "1" ]]; then kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true _CMUX_GIT_JOB_PID="" _CMUX_GIT_JOB_STARTED_AT=0 @@ -154,20 +211,21 @@ _cmux_prompt_command() { fi } >/dev/null 2>&1 & _CMUX_GIT_JOB_PID=$! + disown _CMUX_GIT_JOB_STARTED_AT=$now fi # Pull request metadata (number/state/url): - # refresh on cwd change and periodically to avoid stale status. + # refresh on cwd change, HEAD change, and periodically to avoid stale status. if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]]; then kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true _CMUX_PR_JOB_PID="" _CMUX_PR_JOB_STARTED_AT=0 fi fi - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then _CMUX_PR_LAST_PWD="$pwd" _CMUX_PR_LAST_RUN=$now @@ -197,6 +255,7 @@ _cmux_prompt_command() { fi } >/dev/null 2>&1 & _CMUX_PR_JOB_PID=$! + disown _CMUX_PR_JOB_STARTED_AT=$now fi fi @@ -205,6 +264,7 @@ _cmux_prompt_command() { if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then _cmux_ports_kick fi + } _cmux_install_prompt_command() { diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 0432737f..ee2047a7 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -45,8 +45,8 @@ typeset -g _CMUX_GIT_JOB_STARTED_AT=0 typeset -g _CMUX_GIT_FORCE=0 typeset -g _CMUX_GIT_HEAD_LAST_PWD="" typeset -g _CMUX_GIT_HEAD_PATH="" -typeset -g _CMUX_GIT_HEAD_MTIME=0 -typeset -g _CMUX_HAVE_ZSTAT=0 +typeset -g _CMUX_GIT_HEAD_SIGNATURE="" +typeset -g _CMUX_GIT_HEAD_WATCH_PID="" typeset -g _CMUX_PR_LAST_PWD="" typeset -g _CMUX_PR_LAST_RUN=0 typeset -g _CMUX_PR_JOB_PID="" @@ -155,19 +155,6 @@ _cmux_install_winch_guard() { } _cmux_install_winch_guard -_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" @@ -195,27 +182,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() { @@ -244,6 +219,65 @@ _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_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 @@ -265,9 +299,12 @@ _cmux_preexec() { # Register TTY + kick batched port scan for foreground commands (servers). _cmux_report_tty_once _cmux_ports_kick + _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 @@ -328,6 +365,8 @@ _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 # Git branch can change without a `git ...`-prefixed command (aliases like `gco`, @@ -335,13 +374,13 @@ _cmux_precmd() { if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then _CMUX_GIT_HEAD_LAST_PWD="$pwd" _CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)" - _CMUX_GIT_HEAD_MTIME=0 + _CMUX_GIT_HEAD_SIGNATURE="" fi if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then - local head_mtime - head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)" - if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then - _CMUX_GIT_HEAD_MTIME="$head_mtime" + local head_signature + head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" + if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" # Treat HEAD file change like a git command — force-replace any # running probe so the sidebar picks up the new branch immediately. _CMUX_GIT_FORCE=1 @@ -381,16 +420,7 @@ _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 @@ -488,7 +518,12 @@ _cmux_fix_path() { add-zsh-hook -d precmd _cmux_fix_path } +_cmux_zshexit() { + _cmux_stop_git_head_watch +} + 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 9116527e..358a5dce 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7,6 +7,7 @@ import Sentry import WebKit import Combine import ObjectiveC.runtime +import Darwin enum FinderServicePathResolver { private static func canonicalDirectoryPath(_ path: String) -> String { @@ -54,10 +55,12 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { 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) } + fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, + isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) } ) } @@ -78,31 +81,31 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { var commandPaletteTitle: String { switch self { case .androidStudio: - return "Open Current Directory in Android Studio" + return String(localized: "menu.openInAndroidStudio", defaultValue: "Open Current Directory in Android Studio") case .antigravity: - return "Open Current Directory in Antigravity" + return String(localized: "menu.openInAntigravity", defaultValue: "Open Current Directory in Antigravity") case .cursor: - return "Open Current Directory in Cursor" + return String(localized: "menu.openInCursor", defaultValue: "Open Current Directory in Cursor") case .finder: - return "Open Current Directory in Finder" + return String(localized: "menu.openInFinder", defaultValue: "Open Current Directory in Finder") case .ghostty: - return "Open Current Directory in Ghostty" + return String(localized: "menu.openInGhostty", defaultValue: "Open Current Directory in Ghostty") case .iterm2: - return "Open Current Directory in iTerm2" + return String(localized: "menu.openInITerm2", defaultValue: "Open Current Directory in iTerm2") case .terminal: - return "Open Current Directory in Terminal" + return String(localized: "menu.openInTerminal", defaultValue: "Open Current Directory in Terminal") case .tower: - return "Open Current Directory in Tower" + return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") case .vscode: - return "Open Current Directory in VS Code" + return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") case .warp: - return "Open Current Directory in Warp" + return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") case .windsurf: - return "Open Current Directory in Windsurf" + return String(localized: "menu.openInWindsurf", defaultValue: "Open Current Directory in Windsurf") case .xcode: - return "Open Current Directory in Xcode" + return String(localized: "menu.openInXcode", defaultValue: "Open Current Directory in Xcode") case .zed: - return "Open Current Directory in Zed" + return String(localized: "menu.openInZed", defaultValue: "Open Current Directory in Zed") } } @@ -126,7 +129,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { case .tower: return common + ["tower", "git", "client"] case .vscode: - return common + ["vs", "code", "visual", "studio"] + return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] case .warp: return common + ["warp", "terminal", "shell"] case .windsurf: @@ -139,7 +142,12 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { } func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { - applicationPath(in: environment) != nil + 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? { @@ -225,6 +233,440 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { } } +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 + + 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) + } +#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 + 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. @@ -618,23 +1060,45 @@ func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { func shouldDispatchBrowserReturnViaFirstResponderKeyDown( keyCode: UInt16, - firstResponderIsBrowser: Bool + firstResponderIsBrowser: Bool, + flags: NSEvent.ModifierFlags ) -> Bool { guard firstResponderIsBrowser else { return false } - return keyCode == 36 || keyCode == 76 + 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 + keyCode: UInt16, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) ) -> Bool { let normalizedFlags = flags .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) guard normalizedFlags == [.command, .control] else { return false } let normalizedChars = chars.lowercased() - return normalizedChars == "f" || keyCode == 3 + if normalizedChars == "f" { + return true + } + let charsAreControlSequence = !normalizedChars.isEmpty + && normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) } + if !normalizedChars.isEmpty && !charsAreControlSequence { + return false + } + + // Fallback to layout translation only when characters are unavailable (for + // synthetic/key-equivalent paths that can report an empty string). + if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty { + return translatedCharacter == "f" + } + + // Keep ANSI fallback as a final safety net when layout translation is unavailable. + return keyCode == 3 } func commandPaletteSelectionDeltaForKeyboardNavigation( @@ -673,6 +1137,13 @@ func shouldConsumeShortcutWhileCommandPaletteVisible( 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() @@ -702,6 +1173,43 @@ func shouldConsumeShortcutWhileCommandPaletteVisible( 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 @@ -928,10 +1436,13 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> 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 } @@ -942,6 +1453,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 @@ -990,6 +1508,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent weak var sidebarState: SidebarState? weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? + var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) private var workspaceObserver: NSObjectProtocol? private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] private var windowKeyObserver: NSObjectProtocol? @@ -1012,7 +1531,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:)) @@ -1100,7 +1619,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var isApplyingStartupSessionRestore = false private var sessionAutosaveTimer: DispatchSourceTimer? private var socketListenerHealthTimer: DispatchSourceTimer? - private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(5) + private var socketListenerHealthCheckInFlight = false + private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2) private var lastSocketListenerUnhealthyCaptureAt: Date = .distantPast private static let socketListenerUnhealthyCaptureCooldown: TimeInterval = 60 private let sessionPersistenceQueue = DispatchQueue( @@ -1121,8 +1641,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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 var updateViewModel: UpdateViewModel { updateController.viewModel @@ -1201,6 +1727,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif 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 @@ -1228,6 +1763,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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. @@ -1238,6 +1775,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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() @@ -1330,13 +1873,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sentryBreadcrumb("app.didBecomeActive", category: "lifecycle", data: [ "tabCount": tabManager?.tabs.count ?? 0 ]) - let env = ProcessInfo.processInfo.environment - if TelemetrySettings.enabledForCurrentLaunch && !isRunningUnderXCTest(env) { - PostHogAnalytics.shared.trackDailyActive(reason: "didBecomeActive") - PostHogAnalytics.shared.trackHourlyActive(reason: "didBecomeActive") + 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 } @@ -1360,6 +1903,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent stopSessionAutosaveTimer() stopSocketListenerHealthMonitor() TerminalController.shared.stop() + VSCodeServeWebController.shared.stop() BrowserHistoryStore.shared.flushPendingSaves() if TelemetrySettings.enabledForCurrentLaunch { PostHogAnalytics.shared.flush() @@ -2016,25 +2560,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func stopSocketListenerHealthMonitor() { socketListenerHealthTimer?.cancel() socketListenerHealthTimer = nil + socketListenerHealthCheckInFlight = false } private func restartSocketListenerIfNeededForHealthCheck(source: String) { - guard let config = socketListenerConfigurationIfEnabled() else { return } - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) + guard !socketListenerHealthCheckInFlight, + let config = socketListenerConfigurationIfEnabled() else { return } + let expectedSocketPath = config.path + let terminalController = TerminalController.shared + socketListenerHealthCheckInFlight = true + Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in + let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath) + Task { @MainActor [weak self, health] in + guard let self else { return } + self.socketListenerHealthCheckInFlight = false + self.handleSocketListenerHealthCheckResult( + health, + source: source, + expectedSocketPath: expectedSocketPath + ) + } + } + } + + private func handleSocketListenerHealthCheckResult( + _ health: TerminalController.SocketListenerHealth, + source: String, + expectedSocketPath: String + ) { + guard let config = socketListenerConfigurationIfEnabled(), + config.path == expectedSocketPath else { return } guard !health.isHealthy else { lastSocketListenerUnhealthyCaptureAt = .distantPast return } let failureSignals = health.failureSignals - let data: [String: Any] = [ + var data: [String: Any] = [ "source": source, "path": config.path, "isRunning": health.isRunning ? 1 : 0, "acceptLoopAlive": health.acceptLoopAlive ? 1 : 0, "socketPathMatches": health.socketPathMatches ? 1 : 0, "socketPathExists": health.socketPathExists ? 1 : 0, + "socketProbePerformed": health.socketProbePerformed ? 1 : 0, "failureSignals": failureSignals ] + if let socketConnectable = health.socketConnectable { + data["socketConnectable"] = socketConnectable ? 1 : 0 + } + if let socketConnectErrno = health.socketConnectErrno { + data["socketConnectErrno"] = Int(socketConnectErrno) + } sentryBreadcrumb("socket.listener.unhealthy", category: "socket", data: data) let now = Date() if now.timeIntervalSince(lastSocketListenerUnhealthyCaptureAt) >= Self.socketListenerUnhealthyCaptureCooldown { @@ -2838,9 +3414,212 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowForMainWindowId(windowId) } + 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 { @@ -2950,6 +3729,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 { @@ -2965,6 +3778,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() { @@ -3003,9 +3849,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var labels: [UUID: String] = [:] for (index, summary) in orderedSummaries.enumerated() { if summary.windowId == referenceWindowId { - labels[summary.windowId] = "Current Window" + labels[summary.windowId] = String(localized: "menu.currentWindow", defaultValue: "Current Window") } else { - labels[summary.windowId] = "Window \(index + 1)" + let number = index + 1 + labels[summary.windowId] = String(localized: "menu.windowNumber", defaultValue: "Window \(number)") } } return labels @@ -3013,7 +3860,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func workspaceDisplayName(_ workspace: Workspace) -> String { let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "Workspace" : trimmed + return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed } private func rollbackDetachedSurface( @@ -3180,20 +4027,82 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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, - let windowId = mainWindowId(for: keyWindow), - commandPaletteVisibilityByWindowId[windowId] == true { + isMainTerminalWindow(keyWindow), + isCommandPaletteEffectivelyVisible(in: keyWindow) { return keyWindow } if let mainWindow = NSApp.mainWindow, - let windowId = mainWindowId(for: mainWindow), - commandPaletteVisibilityByWindowId[windowId] == true { + 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 } @@ -3304,6 +4213,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) #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 { @@ -3882,22 +4803,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let installer = CmuxCLIPathInstaller() do { let outcome = try installer.install() - var informativeText = """ - Created symlink: - - \(outcome.destinationURL.path) -> \(outcome.sourceURL.path) - """ + var informativeText = String(localized: "cli.install.symlinkCreated", defaultValue: "Created symlink:\n\n\(outcome.destinationURL.path) -> \(outcome.sourceURL.path)") if outcome.usedAdministratorPrivileges { - informativeText += "\n\nAdministrator privileges were required to write to /usr/local/bin." + informativeText += "\n\n" + String(localized: "cli.install.adminRequired", defaultValue: "Administrator privileges were required to write to /usr/local/bin.") } presentCLIPathAlert( - title: "cmux CLI Installed", + title: String(localized: "cli.installed", defaultValue: "cmux CLI Installed"), informativeText: informativeText, style: .informational ) } catch { presentCLIPathAlert( - title: "Couldn't Install cmux CLI", + title: String(localized: "cli.installFailed", defaultValue: "Couldn't Install cmux CLI"), informativeText: error.localizedDescription, style: .warning ) @@ -3909,20 +4826,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent do { let outcome = try installer.uninstall() let prefix = outcome.removedExistingEntry - ? "Removed \(outcome.destinationURL.path)." - : "No cmux CLI symlink was found at \(outcome.destinationURL.path)." + ? 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\nAdministrator privileges were required to modify /usr/local/bin." + informativeText += "\n\n" + String(localized: "cli.uninstall.adminRequired", defaultValue: "Administrator privileges were required to modify /usr/local/bin.") } presentCLIPathAlert( - title: "cmux CLI Uninstalled", + title: String(localized: "cli.uninstalled", defaultValue: "cmux CLI Uninstalled"), informativeText: informativeText, style: .informational ) } catch { presentCLIPathAlert( - title: "Couldn't Uninstall cmux CLI", + title: String(localized: "cli.uninstallFailed", defaultValue: "Couldn't Uninstall cmux CLI"), informativeText: error.localizedDescription, style: .warning ) @@ -3938,7 +4855,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent alert.alertStyle = style alert.messageText = title alert.informativeText = informativeText - alert.addButton(withTitle: "OK") + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) if let window = NSApp.keyWindow ?? NSApp.mainWindow { alert.beginSheetModal(for: window, completionHandler: nil) @@ -3992,8 +4909,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @MainActor static func presentPreferencesWindow( - showFallbackSettingsWindow: @MainActor () -> Void = { - SettingsWindowController.shared.show() + navigationTarget: SettingsNavigationTarget? = nil, + showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in + SettingsWindowController.shared.show(navigationTarget: target) }, activateApplication: @MainActor () -> Void = { NSApp.activate(ignoringOtherApps: true) @@ -4002,7 +4920,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG dlog("settings.open.present path=customWindowDirect") #endif - showFallbackSettingsWindow() + showFallbackSettingsWindow(navigationTarget) activateApplication() #if DEBUG dlog("settings.open.present activate=1") @@ -4010,11 +4928,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @MainActor - func openPreferencesWindow(debugSource: String) { + func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { #if DEBUG dlog("settings.open.request source=\(debugSource)") #endif - Self.presentPreferencesWindow() + Self.presentPreferencesWindow(navigationTarget: navigationTarget) } @objc func openPreferencesWindow() { @@ -4103,6 +5021,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #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 @objc func openDebugScrollbackTab(_ sender: Any?) { guard let tabManager else { return } @@ -4153,6 +5078,280 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + @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 primeStats = await self.primeDebugStressWorkspacesForSurfaceLoad(created) + // Avoid synchronous "load all surfaces" waiting in this command path. + // Waiting for every background surface to be ready creates sustained + // main-actor churn and can starve typing responsiveness. + let loadStats = DebugStressSurfaceLoadStats( + pendingSurfaces: self.pendingDebugTerminalSurfaceCount(in: created), + attempts: 0, + elapsedMs: 0 + ) + 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)) " + + "primeMs=\(String(format: "%.2f", primeStats.elapsedMs)) primedTabs=\(primeStats.activatedTabs) " + + "waitMs=\(String(format: "%.2f", loadStats.elapsedMs)) 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 primeMs=%.2f primedTabs=%d waitMs=%.2f totalMs=%.2f workspaceAvgMs=%.2f workspaceWorstMs=%.2f waitAttempts=%d", + self.debugStressWorkspaceCount, + self.debugStressPaneCount, + self.debugStressTabsPerPane, + expectedSurfaceCount, + layoutFailures, + loadStats.pendingSurfaces, + creationElapsedMs, + primeStats.elapsedMs, + primeStats.activatedTabs, + loadStats.elapsedMs, + totalElapsedMs, + avgWorkspaceMs, + worstWorkspaceMs, + loadStats.attempts + ) + } + } + + private struct DebugStressSurfacePrimeStats { + let activatedTabs: Int + let elapsedMs: Double + } + + private func primeDebugStressWorkspacesForSurfaceLoad( + _ workspaces: [Workspace] + ) async -> DebugStressSurfacePrimeStats { + guard !workspaces.isEmpty else { + return DebugStressSurfacePrimeStats(activatedTabs: 0, elapsedMs: 0) + } + + let primeStart = ProcessInfo.processInfo.systemUptime + var activatedTabs = 0 + + for (index, workspace) in workspaces.enumerated() { + activatedTabs += workspace.panels.values.reduce(into: 0) { count, panel in + if panel is TerminalPanel { + count += 1 + } + } + + if (index + 1) % debugStressYieldInterval == 0 || index == workspaces.count - 1 { + dlog( + "stress.setup.mount idx=\(index + 1)/\(workspaces.count) activatedTabs=\(activatedTabs)" + ) + await Task.yield() + } + } + + let elapsedMs = (ProcessInfo.processInfo.systemUptime - primeStart) * 1000.0 + return DebugStressSurfacePrimeStats(activatedTabs: activatedTabs, elapsedMs: elapsedMs) + } + + 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 attempts: Int + let elapsedMs: Double + } + + 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)" + ) + } + private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) { let maxAttempts = 60 if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { @@ -4380,11 +5579,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 { @@ -4423,6 +5676,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 } @@ -4468,6 +5724,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( @@ -4478,6 +5735,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") }) } @@ -4505,11 +5763,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 { @@ -4523,15 +6102,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 @@ -4546,16 +6123,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent directionValue = "down" } - writeGotoSplitTestData([ - "lastSplitDirection": directionValue, - "paneCountAfterSplit": String(workspace.bonsplitController.allPaneIds.count), - "focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? "" - ]) + var updates = gotoSplitFindStateSnapshot(for: workspace) + updates["lastSplitDirection"] = directionValue + updates["paneCountAfterSplit"] = String(workspace.bonsplitController.allPaneIds.count) + writeGotoSplitTestData(updates) } private func writeGotoSplitTestData(_ updates: [String: String]) { - let env = ProcessInfo.processInfo.environment - guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return } + guard let path = gotoSplitUITestDataPath() else { return } var payload = loadGotoSplitTestData(at: path) for (key, value) in updates { payload[key] = value @@ -4582,19 +6157,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 } @@ -4608,39 +6228,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 { @@ -4695,6 +6469,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 @@ -4752,27 +6531,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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 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 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))" + ) + } if let probeKind = self.developerToolsShortcutProbeKind(event: event) { self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) } #endif - if self.handleCustomShortcut(event: event) { + let shortcutStart = ProcessInfo.processInfo.systemUptime + let handledByShortcut = self.handleCustomShortcut(event: event) +#if DEBUG + let shortcutElapsedMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 + self.logSlowShortcutMonitorLatencyIfNeeded( + event: event, + handledByShortcut: handledByShortcut, + elapsedMs: shortcutElapsedMs + ) +#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 } } @@ -4942,12 +6735,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 { @@ -4969,14 +6762,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let alert = NSAlert() - alert.messageText = "Rename Workspace" - alert.informativeText = "Enter a custom name for this workspace." + 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 = "Workspace name" + 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: "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 { @@ -4992,7 +6785,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func handleCustomShortcut(event: NSEvent) -> Bool { // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. - // Most shortcuts below use keyCode fallbacks, so treat nil as "" rather than bailing out. + // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. let chars = (event.charactersIgnoringModifiers ?? "").lowercased() let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let hasControl = flags.contains(.control) @@ -5029,7 +6822,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Special-case: Cmd+D should confirm destructive close on alerts. // XCUITest key events often hit the app-level local monitor first, so forward the key // equivalent to the alert panel explicitly. - if flags == [.command], chars == "d", + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) + ), let root = closeConfirmationPanel.contentView, let closeButton = findButton(in: root, titled: "Close") { closeButton.performClick(nil) @@ -5044,9 +6840,109 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) - let commandPaletteVisibleInTargetWindow = commandPaletteTargetWindow.map { + 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, @@ -5054,7 +6950,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent keyCode: event.keyCode ), commandPaletteVisibleInTargetWindow, - let paletteWindow = commandPaletteTargetWindow { + let paletteWindow = commandPaletteShortcutWindow { NotificationCenter.default.post( name: .commandPaletteMoveSelection, object: paletteWindow, @@ -5063,13 +6959,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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 - dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") + 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() @@ -5081,23 +7005,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // focused omnibar in another window does not suppress Cmd+P here. let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil let isCommandP = !hasFocusedAddressBarInShortcutContext - && normalizedFlags == [.command] - && (chars == "p" || event.keyCode == 35) + && matchShortcut( + event: event, + shortcut: StoredShortcut(key: "p", command: true, shift: false, option: false, control: false) + ) if isCommandP { let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.cmdP") return true } - let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35) + let isCommandShiftP = matchShortcut( + event: event, + shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) + ) if isCommandShiftP { let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP") return true } if shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: commandPaletteVisibleInTargetWindow, + isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, normalizedFlags: normalizedFlags, chars: chars, keyCode: event.keyCode @@ -5105,11 +7034,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if normalizedFlags == [.command], chars == "q" { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) + ) { return handleQuitShortcutWarning() } - if normalizedFlags == [.command, .shift], - (chars == "," || chars == "<" || event.keyCode == 43) { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) + ) { GhosttyApp.shared.reloadConfiguration(source: "shortcut.cmd_shift_comma") return true } @@ -5193,6 +7127,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) { @@ -5253,6 +7194,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 @@ -5280,6 +7232,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 @@ -5309,7 +7274,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - if normalizedFlags == [.command, .option], (chars == "t" || event.keyCode == 17) { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "t", command: true, shift: false, option: true, control: false) + ) { if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) @@ -5330,7 +7298,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Cmd+W must close the focused panel even if first-responder momentarily lags on a // browser NSTextView during split focus transitions. - if normalizedFlags == [.command], (chars == "w" || event.keyCode == 13) { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) + ) { if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) @@ -5379,7 +7350,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") return true } @@ -5488,11 +7459,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } 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 } @@ -5553,7 +7530,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 @@ -5696,6 +7676,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } 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 @@ -5703,9 +7704,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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 } @@ -5713,16 +7733,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @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? { @@ -5731,11 +7791,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { guard let panelId = browserAddressBarFocusedPanelId else { return nil } - guard let context = preferredMainWindowContextForShortcutRouting(event: event), - let workspace = context.tabManager.selectedWorkspace, - workspace.browserPanel(for: panelId) != nil else { + + 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 } @@ -5752,7 +7845,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] guard isCommandOrControlOnly else { return false } - return chars == "n" || chars == "p" + 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( @@ -5769,6 +7872,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, @@ -5778,15 +7887,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() @@ -5798,11 +7929,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 @@ -5813,12 +7954,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) { @@ -5827,11 +7980,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: @@ -6006,7 +8171,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) - guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false } + #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 } @@ -6021,7 +8217,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - NotificationCenter.default.post(name: .commandPaletteRenameWorkspaceRequested, object: targetWindow) + requestCommandPaletteRenameWorkspace( + preferredWindow: targetWindow, + source: "shortcut.renameWorkspace" + ) return true } @@ -6033,6 +8232,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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 @@ -6086,42 +8306,127 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - /// Match a shortcut against an event, handling normal keys + /// Match a shortcut against an event, handling normal keys. private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { // Some keys can include extra flags (e.g. .function) depending on the responder chain. // Strip those for consistent matching across first responders (terminal, WebKit, etc). let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + .subtracting([.numericPad, .function, .capsLock]) guard flags == shortcut.modifierFlags else { return false } - // NSEvent.charactersIgnoringModifiers preserves Shift for some symbol keys - // (e.g. Shift+] can yield "}" instead of "]"), so match brackets by keyCode. let shortcutKey = shortcut.key.lowercased() if shortcutKey == "\r" { return event.keyCode == 36 || event.keyCode == 76 } - if shortcutKey == "[" || shortcutKey == "]" { - switch event.keyCode { - case 33: // kVK_ANSI_LeftBracket - return shortcutKey == "[" - case 30: // kVK_ANSI_RightBracket - return shortcutKey == "]" - default: - return false - } - } - // Control-key combos can produce control characters (e.g. Ctrl+H => backspace), - // so fall back to keyCode matching for common printable keys. - if let chars = event.charactersIgnoringModifiers?.lowercased(), chars == shortcutKey { + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + if shortcutCharacterMatches( + eventCharacter: eventCharsIgnoringModifiers, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { return true } - if let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { + + // For command-based shortcuts, trust AppKit's layout-aware characters when present. + // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, + // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. + let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + if hasEventChars, + flags.contains(.command), + !flags.contains(.control), + shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { + return false + } + + // Match using the current keyboard layout so Command shortcuts stay character-based + // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if shortcutCharacterMatches( + eventCharacter: layoutCharacter, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return true + } + + // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), + // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for + // command punctuation shortcuts, since some non-US layouts report different characters + // for the same physical key even when menu-equivalent semantics should still apply. + let allowANSIKeyCodeFallback = flags.contains(.control) + || (flags.contains(.command) + && !flags.contains(.control) + && ( + !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) + || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) + )) + if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { return event.keyCode == expectedKeyCode } return false } + private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { + guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { + return false + } + return CharacterSet.letters.contains(scalar) + } + + private func shortcutCharacterMatches( + eventCharacter: String?, + shortcutKey: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Bool { + guard let eventCharacter, !eventCharacter.isEmpty else { return false } + if normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) == shortcutKey { + return true + } + return false + } + + private func normalizedShortcutEventCharacter( + _ eventCharacter: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> String { + let lowered = eventCharacter.lowercased() + guard applyShiftSymbolNormalization else { return lowered } + + switch lowered { + case "{": return "[" + case "}": return "]" + case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma + case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period + case "?": return "/" + case ":": return ";" + case "\"": return "'" + case "|": return "\\" + case "~": return "`" + case "+": return "=" + case "_": return "-" + case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 + case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 + case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 + case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 + case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 + case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 + case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 + case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 + case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 + case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 + default: return lowered + } + } + private func keyCodeForShortcutKey(_ key: String) -> UInt16? { // Matches macOS ANSI key codes. This is intentionally limited to keys we // support in StoredShortcut/ghostty trigger translation. @@ -6155,8 +8460,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 @@ -6249,8 +8556,14 @@ 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) } } @@ -6317,6 +8630,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( @@ -6327,6 +8644,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 { @@ -6350,7 +8673,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) { @@ -6468,6 +8795,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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) @@ -6804,17 +9135,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 @@ -6943,7 +9274,9 @@ 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") } } @@ -7066,9 +9399,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") + } } } @@ -7370,6 +9708,7 @@ enum MenuBarIconRenderer { drawBadge(text: text, in: config.badgeRect, config: config) } + image.isTemplate = true return image } @@ -7398,7 +9737,7 @@ enum MenuBarIconRenderer { path.line(to: map(384.0, 369.0)) path.close() - NSColor.white.setFill() + NSColor.black.setFill() path.fill() } @@ -7428,6 +9767,9 @@ enum MenuBarIconRenderer { 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 @@ -7469,6 +9811,7 @@ private extension NSWindow { let responderWebView = responder.flatMap { Self.cmuxOwningWebView(for: $0, in: self, event: currentEvent) } + var pointerInitiatedWebFocus = false if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( window: self, @@ -7492,6 +9835,7 @@ private extension NSWindow { event: currentEvent ) if pointerInitiatedFocus { + pointerInitiatedWebFocus = true #if DEBUG dlog( "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + @@ -7528,7 +9872,16 @@ private extension NSWindow { ) } #endif - let result = cmux_makeFirstResponder(responder) + 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) @@ -7540,6 +9893,18 @@ private extension NSWindow { } @objc func cmux_sendEvent(_ event: NSEvent) { + let previousContextEvent = cmuxFirstResponderGuardCurrentEventContext + let previousContextHitView = cmuxFirstResponderGuardHitViewContext + let previousContextWindowNumber = cmuxFirstResponderGuardContextWindowNumber + cmuxFirstResponderGuardCurrentEventContext = event + cmuxFirstResponderGuardHitViewContext = Self.cmuxHitViewForEventDispatch(in: self, event: event) + cmuxFirstResponderGuardContextWindowNumber = self.windowNumber + defer { + cmuxFirstResponderGuardCurrentEventContext = previousContextEvent + cmuxFirstResponderGuardHitViewContext = previousContextHitView + cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber + } + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), let contentView = self.contentView else { cmux_sendEvent(event) @@ -7636,7 +10001,8 @@ private extension NSWindow { // mark handled to avoid the AppKit alert sound path. if shouldDispatchBrowserReturnViaFirstResponderKeyDown( keyCode: event.keyCode, - firstResponderIsBrowser: firstResponderWebView != nil + 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. @@ -7770,28 +10136,89 @@ private extension NSWindow { if let webView = candidate as? CmuxWebView { return webView } + if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"), + let portalWebView = cmuxUniqueBrowserWebView(in: candidate) { + return portalWebView + } current = candidate.superview } return nil } - private static func cmuxCurrentEvent(for _: NSWindow) -> NSEvent? { + 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 - return window.contentView?.hitTest(event.locationInWindow) + if cmuxFirstResponderGuardContextWindowNumber == window.windowNumber, + let contextHitView = cmuxFirstResponderGuardHitViewContext { + return contextHitView + } + return cmuxTopHitViewForEvent(in: window, event: event) } private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) { @@ -7843,6 +10270,12 @@ private extension NSWindow { 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 } diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 1a5ea166..97151a08 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1,9 +1,8 @@ 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 @@ -20,12 +19,96 @@ 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 WKWebView { + func browserPortalNotifyHidden(reason: String) { + 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 window != nil else { return } + + 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 + } +} + 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 + } + + private struct HostedInspectorDividerDragState { + let slotView: WindowBrowserSlotView + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + let initialWindowX: CGFloat + let initialPageFrame: NSRect + let initialInspectorFrame: NSRect + } + private enum DividerCursorKind: Equatable { case vertical case horizontal @@ -41,10 +124,61 @@ 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? + + 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 +198,29 @@ final class WindowBrowserHostView: NSView { window?.invalidateCursorRects(for: self) } + override func layout() { + super.layout() + 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,21 +269,204 @@ 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 shouldPassThroughToTitlebar(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 shouldPassThroughToSidebarResizer(at: point) { + if sidebarPassThrough { +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.sidebarPass", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: true, + dividerHit: dividerHit, + hitView: nil + ) +#endif return nil } - if shouldPassThroughToSplitDivider(at: point) { + 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 + } + + hostedInspectorDividerDrag = HostedInspectorDividerDragState( + slotView: hostedInspectorHit.slotView, + containerView: hostedInspectorHit.containerView, + pageView: hostedInspectorHit.pageView, + inspectorView: hostedInspectorHit.inspectorView, + 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 { + hostedInspectorDividerDrag = nil + super.mouseDragged(with: event) + return + } + + let containerBounds = dragState.containerView.bounds + let minimumInspectorWidth = min( + Self.minimumHostedInspectorWidth, + max(60, dragState.initialInspectorFrame.width) + ) + let minDividerX = max(containerBounds.minX, dragState.initialPageFrame.minX) + let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth) + let proposedDividerX = dragState.initialInspectorFrame.minX + (event.locationInWindow.x - dragState.initialWindowX) + let clampedDividerX = max(minDividerX, min(maxDividerX, proposedDividerX)) + let inspectorWidth = max(0, containerBounds.maxX - clampedDividerX) + + dragState.slotView.preferredHostedInspectorWidth = inspectorWidth + let appliedFrames = applyHostedInspectorDividerWidth( + inspectorWidth, + to: HostedInspectorDividerHit( + slotView: dragState.slotView, + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView + ), + 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 + ) + ) +#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 { +#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 @@ -143,6 +480,31 @@ final class WindowBrowserHostView: NSView { } 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 } @@ -193,13 +555,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 } @@ -207,6 +580,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) @@ -216,18 +609,234 @@ 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.filter { candidate in + guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return false } + guard candidate !== inspectorView else { return false } + guard candidate.frame.maxX <= inspectorView.frame.minX + 1 else { return false } + return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 + } + + if let pageView = pageCandidates.max(by: { + hostedInspectorPageCandidateScore($0, inspectorView: inspectorView) + < hostedInspectorPageCandidateScore($1, inspectorView: inspectorView) + }) { + bestHit = HostedInspectorDividerHit( + slotView: slot, + containerView: containerView, + pageView: pageView, + inspectorView: inspectorView + ) + } + + 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) + let minY = max(slotBounds.minY, min(pageFrame.minY, inspectorFrame.minY)) + let maxY = min(slotBounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY)) + return NSRect( + x: inspectorFrame.minX - Self.hostedInspectorDividerHitExpansion, + y: minY, + width: Self.hostedInspectorDividerHitExpansion * 2, + height: max(0, maxY - minY) + ) + } + + 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) + } + } + + fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) { + guard let preferredWidth = slot.preferredHostedInspectorWidth else { return } + guard let hit = hostedInspectorDividerCandidate(in: slot) else { return } + _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason) + } + + @discardableResult + private func applyHostedInspectorDividerWidth( + _ preferredWidth: CGFloat, + to hit: HostedInspectorDividerHit, + reason: String + ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { + let containerBounds = hit.containerView.bounds + let maximumInspectorWidth = max(0, containerBounds.maxX - hit.pageView.frame.minX) + let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) + let dividerX = max(hit.pageView.frame.minX, containerBounds.maxX - clampedInspectorWidth) + + var pageFrame = hit.pageView.frame + pageFrame.size.width = max(0, dividerX - pageFrame.minX) + + var inspectorFrame = hit.inspectorView.frame + inspectorFrame.origin.x = dividerX + inspectorFrame.size.width = max(0, containerBounds.maxX - dividerX) + + let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5) + let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, 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 + + hit.pageView.needsLayout = true + hit.inspectorView.needsLayout = true + hit.containerView.needsLayout = true + hit.slotView.needsLayout = true +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=reapply slot=\(browserPortalDebugToken(hit.slotView)) " + + "container=\(browserPortalDebugToken(hit.containerView)) reason=\(reason) " + + "preferredWidth=\(String(format: "%.1f", preferredWidth)) " + + "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 { @@ -265,21 +874,62 @@ 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 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 } @@ -317,8 +967,400 @@ 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 onNext: () -> Void + let onPrevious: () -> Void + let onClose: () -> 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 } + 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? + var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)? + fileprivate var isApplyingHostedInspectorLayout = false override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -326,16 +1368,289 @@ 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 layout() { + super.layout() + paneDropTargetView.frame = bounds + applyResolvedDropZoneOverlay() + guard !isApplyingHostedInspectorLayout else { return } + onHostedInspectorLayout?(self) + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + attachDropZoneOverlayIfNeeded() + applyResolvedDropZoneOverlay() + } + + 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() + } + + func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) { + guard let configuration else { + searchOverlayHostingView?.removeFromSuperview() + searchOverlayHostingView = nil + return + } + + let rootView = BrowserSearchOverlay( + panelId: configuration.panelId, + searchState: configuration.searchState, + onNext: configuration.onNext, + onPrevious: configuration.onPrevious, + onClose: configuration.onClose + ) + + 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), + ]) + } + return + } + + 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), + ]) + searchOverlayHostingView = overlay + } + + func pinHostedWebView(_ webView: WKWebView) { + guard webView.superview === self else { return } + + let needsNewConstraints = + hostedWebView !== webView || + hostedWebViewConstraints.isEmpty || + webView.translatesAutoresizingMaskIntoConstraints + guard needsNewConstraints else { + needsLayout = true + layoutSubtreeIfNeeded() + return + } + + NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebView = webView + webView.translatesAutoresizingMaskIntoConstraints = false + webView.autoresizingMask = [] + hostedWebViewConstraints = [ + webView.topAnchor.constraint(equalTo: topAnchor), + webView.bottomAnchor.constraint(equalTo: bottomAnchor), + webView.leadingAnchor.constraint(equalTo: leadingAnchor), + webView.trailingAnchor.constraint(equalTo: trailingAnchor), + ] + NSLayoutConstraint.activate(hostedWebViewConstraints) + needsLayout = true + layoutSubtreeIfNeeded() + } + + 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? @@ -350,6 +1665,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] = [:] @@ -427,22 +1748,39 @@ final class WindowBrowserPortal: NSObject { hostView.superview?.layoutSubtreeIfNeeded() hostView.layoutSubtreeIfNeeded() synchronizeAllWebViews(excluding: nil, source: "externalGeometry") + + for entry in entriesByWebViewId.values { + guard let webView = entry.webView, + let containerView = entry.containerView, + !containerView.isHidden else { continue } + refreshHostedWebViewPresentation( + webView, + in: containerView, + reason: "externalGeometry" + ) + } } @discardableResult private func ensureInstalled() -> Bool { guard let window else { return false } guard let (container, reference) = installationTarget(for: window) else { return false } + let placementReference = preferredHostPlacementReference(in: container, fallback: reference) if hostView.superview !== container || installedContainerView !== container || installedReferenceView !== reference { hostView.removeFromSuperview() - container.addSubview(hostView, positioned: .above, relativeTo: reference) + container.addSubview(hostView, positioned: .above, relativeTo: placementReference) installedContainerView = container installedReferenceView = reference - } else if !Self.isView(hostView, above: reference, in: container) { - container.addSubview(hostView, positioned: .above, relativeTo: reference) + } else { + let aboveReference = Self.isView(hostView, above: reference, in: container) + let abovePlacementReference = placementReference === reference + || Self.isView(hostView, above: placementReference, in: container) + if !aboveReference || !abovePlacementReference { + container.addSubview(hostView, positioned: .above, relativeTo: placementReference) + } } synchronizeHostFrameToReference() @@ -526,6 +1864,44 @@ final class WindowBrowserPortal: NSObject { ) } + 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 + 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 || @@ -557,11 +1933,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)) " + @@ -571,6 +1959,78 @@ final class WindowBrowserPortal: NSObject { return created } + private func runHostedWebViewRefreshPass( + _ webView: WKWebView, + in containerView: WindowBrowserSlotView, + reason: String, + phase: String + ) { + guard !containerView.isHidden else { return } + + containerView.needsLayout = true + containerView.needsDisplay = true + containerView.setNeedsDisplay(containerView.bounds) + + if let scrollView = webView.enclosingScrollView { + scrollView.needsLayout = true + scrollView.needsDisplay = true + scrollView.setNeedsDisplay(scrollView.bounds) + 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() + webView.browserPortalReattachRenderingState(reason: "\(reason):\(phase)") + containerView.displayIfNeeded() + webView.displayIfNeeded() + (webView.window ?? hostView.window)?.displayIfNeeded() +#if DEBUG + dlog( + "browser.portal.refresh web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) reason=\(reason) " + + "phase=\(phase) frame=\(browserPortalDebugFrame(containerView.frame))" + ) +#endif + } + + private func refreshHostedWebViewPresentation( + _ webView: WKWebView, + in containerView: WindowBrowserSlotView, + reason: String + ) { + guard !containerView.isHidden else { return } + + runHostedWebViewRefreshPass(webView, in: containerView, reason: reason, phase: "immediate") + 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" + ) + } + 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" + ) + } + } + private func moveWebKitRelatedSubviewsIfNeeded( from sourceSuperview: NSView, to containerView: WindowBrowserSlotView, @@ -627,6 +2087,7 @@ final class WindowBrowserPortal: NSObject { "hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)" ) #endif + entry.webView?.browserPortalNotifyHidden(reason: "detach") entry.webView?.removeFromSuperview() entry.containerView?.removeFromSuperview() } @@ -636,11 +2097,82 @@ 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 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 } @@ -648,7 +2180,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 ) @@ -677,7 +2221,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 = { @@ -721,11 +2271,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 { @@ -747,7 +2297,11 @@ final class WindowBrowserPortal: NSObject { hostView.addSubview(containerView, positioned: .above, relativeTo: nil) } - synchronizeWebView(withId: webViewId, source: "bind") + synchronizeWebView( + withId: webViewId, + source: "bind", + forcePresentationRefresh: didChangeAnchor + ) pruneDeadEntries() } @@ -789,9 +2343,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 @@ -803,7 +2402,53 @@ final class WindowBrowserPortal: NSObject { } return } + func hideContainerView(reason: String) { + containerView.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) + containerView.setDropZoneOverlay(zone: nil) + if !containerView.isHidden { + 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.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( @@ -812,10 +2457,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( @@ -825,10 +2490,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( @@ -837,6 +2506,7 @@ final class WindowBrowserPortal: NSObject { ) #endif hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + refreshReasons.append("syncAttachContainer") } if webView.superview !== containerView { #if DEBUG @@ -856,15 +2526,14 @@ 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 frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) let frameInHostRaw = hostView.convert(frameInWindow, from: nil) let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) let hostBounds = hostView.bounds @@ -883,8 +2552,38 @@ 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 + 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 @@ -908,6 +2607,28 @@ final class WindowBrowserPortal: NSObject { tinyFrame || !hasFiniteFrame || outsideHostBounds + let transientRecoveryReason: String? = { + guard entry.visibleInUI else { return nil } + if anchorHidden { return "anchorHidden" } + if !hasFiniteFrame { return "nonFiniteFrame" } + if outsideHostBounds { return "outsideHostBounds" } + if tinyFrame { return "tinyFrame" } + return nil + }() + let didScheduleTransientRecovery: Bool = { + guard let transientRecoveryReason else { return false } + return scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: transientRecoveryReason + ) + }() + let shouldPreserveVisibleOnTransientGeometry = + didScheduleTransientRecovery && + shouldHide && + entry.visibleInUI && + !containerView.isHidden #if DEBUG let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) if frameWasClamped { @@ -934,13 +2655,31 @@ 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) + return + } + } if !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() CATransaction.setDisableActions(true) containerView.frame = targetFrame CATransaction.commit() - webView.needsLayout = true - webView.layoutSubtreeIfNeeded() + refreshReasons.append("frame") } let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) @@ -957,6 +2696,7 @@ final class WindowBrowserPortal: NSObject { "target=\(browserPortalDebugFrame(expectedContainerBounds))" ) #endif + refreshReasons.append("bounds") } let containerBounds = containerView.bounds @@ -985,21 +2725,55 @@ 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.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone) + if revealedForDisplay { + refreshReasons.append("reveal") + } + if forcePresentationRefresh { + refreshReasons.append("anchor") + } + if transientRecoveryReason == nil { + resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) + } + if !shouldHide, !refreshReasons.isEmpty { + refreshHostedWebViewPresentation( + webView, + in: containerView, + reason: "\(source):" + refreshReasons.joined(separator: ",") + ) + } + hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync") #if DEBUG dlog( "browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " + @@ -1026,16 +2800,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 } @@ -1190,12 +2972,72 @@ 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 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) + } + #if DEBUG static func debugPortalCount() -> Int { portalsByWindowId.count diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 30620e15..a67a8a0c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import ImageIO import SwiftUI import ObjectiveC import UniformTypeIdentifiers @@ -227,6 +228,26 @@ 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 @Published var persistedWidth: CGFloat @@ -611,7 +632,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 } @@ -633,9 +659,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 @@ -1087,6 +1112,23 @@ private final class WindowCommandPaletteOverlayController: NSObject { 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 @@ -1099,6 +1141,40 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind 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. static let maxMountedWorkspaces = 1 @@ -1233,11 +1309,30 @@ struct ContentView: View { @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) @@ -1256,6 +1351,16 @@ struct ContentView: View { 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) @@ -1268,27 +1373,27 @@ struct ContentView: View { var title: String { switch kind { case .workspace: - return "Rename Workspace" + return String(localized: "commandPalette.rename.workspaceTitle", defaultValue: "Rename Workspace") case .tab: - return "Rename Tab" + return String(localized: "commandPalette.rename.tabTitle", defaultValue: "Rename Tab") } } var description: String { switch kind { case .workspace: - return "Choose a custom workspace name." + return String(localized: "commandPalette.rename.workspaceDescription", defaultValue: "Choose a custom workspace name.") case .tab: - return "Choose a custom tab name." + return String(localized: "commandPalette.rename.tabDescription", defaultValue: "Choose a custom tab name.") } } var placeholder: String { switch kind { case .workspace: - return "Workspace name" + return String(localized: "commandPalette.rename.workspacePlaceholder", defaultValue: "Workspace name") case .tab: - return "Tab name" + return String(localized: "commandPalette.rename.tabPlaceholder", defaultValue: "Tab name") } } } @@ -1349,7 +1454,7 @@ struct ContentView: View { } } - private struct CommandPaletteUsageEntry: Codable { + private struct CommandPaletteUsageEntry: Codable, Sendable { var useCount: Int var lastUsedAt: TimeInterval } @@ -1377,6 +1482,13 @@ struct ContentView: View { func string(_ key: String) -> String? { stringValues[key] } + + func fingerprint() -> Int { + ContentView.commandPaletteContextFingerprint( + boolValues: boolValues, + stringValues: stringValues + ) + } } private enum CommandPaletteContextKeys { @@ -1453,6 +1565,12 @@ struct ContentView: View { 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 @@ -1460,12 +1578,27 @@ struct ContentView: View { 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 @@ -1756,6 +1889,7 @@ struct ContentView: View { private var sidebarView: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, + onSendFeedback: presentFeedbackComposer, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex @@ -1778,6 +1912,7 @@ struct ContentView: View { ForEach(mountedWorkspaces) { tab in let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id + 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 @@ -1802,15 +1937,21 @@ struct ContentView: View { ) .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) { @@ -1831,19 +1972,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 @@ -1902,7 +2035,17 @@ struct ContentView: View { .frame(height: titlebarPadding) .frame(maxWidth: .infinity) .contentShape(Rectangle()) - .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)) @@ -2036,7 +2179,7 @@ struct ContentView: View { .padding(.top, 4) } } - .frame(minWidth: 800, minHeight: 600) + .frame(minWidth: CGFloat(SessionPersistencePolicy.minimumWindowWidth), minHeight: CGFloat(SessionPersistencePolicy.minimumWindowHeight)) .background(Color.clear) ) @@ -2143,6 +2286,10 @@ struct ContentView: View { reconcileMountedWorkspaceIds() }) + view = AnyView(view.onReceive(tabManager.$pendingBackgroundWorkspaceLoadIds) { _ 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 } @@ -2203,6 +2350,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 { @@ -2261,6 +2409,30 @@ struct ContentView: View { 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( @@ -2323,6 +2495,17 @@ struct ContentView: View { _ = 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) @@ -2390,6 +2573,9 @@ struct ContentView: View { }) view = AnyView(view.ignoresSafeArea()) + view = AnyView(view.sheet(isPresented: $isFeedbackComposerPresented) { + SidebarFeedbackComposerSheet() + }) view = AnyView(view.onDisappear { removeSidebarResizerPointerMonitor() @@ -2435,23 +2621,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) @@ -2475,9 +2669,10 @@ 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) let isCycleHot = tabManager.isWorkspaceCycleHot - let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty + let shouldKeepHandoffPair = isCycleHot && !handoffPinnedIds.isEmpty let baseMaxMounted = shouldKeepHandoffPair ? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle : WorkspaceMountPolicy.maxMountedWorkspaces @@ -2514,11 +2709,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 } @@ -2591,13 +2871,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 @@ -2623,9 +2904,18 @@ struct ContentView: View { Color.clear .ignoresSafeArea() .contentShape(Rectangle()) - .onTapGesture { - dismissCommandPalette() - } + .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 { @@ -2658,7 +2948,7 @@ struct ContentView: View { } private var commandPaletteCommandListView: some View { - let visibleResults = Array(commandPaletteResults) + let visibleResults = commandPaletteVisibleResults let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 @@ -2674,8 +2964,9 @@ struct ContentView: View { .font(.system(size: 13, weight: .regular)) .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) .focused($isCommandPaletteSearchFocused) + .accessibilityIdentifier("CommandPaletteSearchField") .onSubmit { - runSelectedCommandPaletteResult(visibleResults: visibleResults) + runSelectedCommandPaletteResult() } .backport.onKeyPress(.downArrow) { _ in moveCommandPaletteSelection(by: 1) @@ -2697,7 +2988,6 @@ struct ContentView: View { .backport.onKeyPress("k") { modifiers in handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) } - } .padding(.horizontal, 9) .padding(.vertical, 7) @@ -2707,12 +2997,18 @@ struct ContentView: View { ScrollView { LazyVStack(spacing: 0) { if visibleResults.isEmpty { - Text(commandPaletteEmptyStateText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 12) + 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 @@ -2722,7 +3018,7 @@ struct ContentView: View { : (isHovered ? Color.primary.opacity(0.08) : .clear) Button { - runCommandPaletteCommand(result.command) + runCommandPaletteResult(commandID: result.id) } label: { HStack(spacing: 8) { commandPaletteHighlightedTitleText( @@ -2797,20 +3093,35 @@ struct ContentView: View { } .onAppear { commandPaletteHoveredResultIndex = nil - updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) + 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: visibleResults.count) { _ in - commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) - if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count { + .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() @@ -2827,6 +3138,7 @@ struct ContentView: View { .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) } @@ -2841,7 +3153,7 @@ struct ContentView: View { Divider() - Text("Enter a \(renameTargetNoun(target)) name. Press Enter to rename, Escape to cancel.") + Text(renameInputHintText(target: target)) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) @@ -2870,7 +3182,7 @@ struct ContentView: View { proposedName: String ) -> some View { let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) - let nextName = trimmedName.isEmpty ? "(clear custom name)" : trimmedName + let nextName = trimmedName.isEmpty ? String(localized: "commandPalette.rename.clearCustomName", defaultValue: "(clear custom name)") : trimmedName return VStack(spacing: 0) { Text(nextName) @@ -2882,7 +3194,7 @@ struct ContentView: View { Divider() - Text("Press Enter to apply this \(renameTargetNoun(target)) name, or Escape to cancel.") + Text(renameConfirmHintText(target: target)) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) @@ -2903,12 +3215,21 @@ struct ContentView: View { } } - private func renameTargetNoun(_ target: CommandPaletteRenameTarget) -> String { + private func renameInputHintText(target: CommandPaletteRenameTarget) -> String { switch target.kind { case .workspace: - return "workspace" + return String(localized: "commandPalette.rename.workspaceInputHint", defaultValue: "Enter a workspace name. Press Enter to rename, Escape to cancel.") case .tab: - return "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.") } } @@ -2919,21 +3240,25 @@ struct ContentView: View { return .switcher } + private var commandPaletteCurrentSearchFingerprint: Int { + commandPaletteEntriesFingerprint(for: commandPaletteListScope) + } + private var commandPaletteSearchPlaceholder: String { switch commandPaletteListScope { case .commands: - return "Type a command" + return String(localized: "commandPalette.search.commandsPlaceholder", defaultValue: "Type a command") case .switcher: - return "Search workspaces and tabs" + return String(localized: "commandPalette.search.switcherPlaceholder", defaultValue: "Search workspaces") } } private var commandPaletteEmptyStateText: String { switch commandPaletteListScope { case .commands: - return "No commands match your search." + return String(localized: "commandPalette.search.commandsEmpty", defaultValue: "No commands match your search.") case .switcher: - return "No workspaces or tabs match your search." + return String(localized: "commandPalette.search.switcherEmpty", defaultValue: "No workspaces match your search.") } } @@ -2947,8 +3272,8 @@ struct ContentView: View { } } - private var commandPaletteEntries: [CommandPaletteCommand] { - switch commandPaletteListScope { + private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] { + switch scope { case .commands: return commandPaletteCommands() case .switcher: @@ -2956,39 +3281,360 @@ struct ContentView: View { } } - private var commandPaletteResults: [CommandPaletteSearchResult] { - let entries = commandPaletteEntries + 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 queryIsEmpty = query.isEmpty + 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 - let results: [CommandPaletteSearchResult] = queryIsEmpty - ? entries.map { entry in - CommandPaletteSearchResult( - command: entry, - score: commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: true), - titleMatchIndices: [] - ) - } - : entries.compactMap { entry in - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(query: query, candidates: entry.searchableTexts) else { - return nil + 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 } - return CommandPaletteSearchResult( - command: entry, - score: fuzzyScore + commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: false), - 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.command.rank != rhs.command.rank { return lhs.command.rank < rhs.command.rank } - return lhs.command.title.localizedCaseInsensitiveCompare(rhs.command.title) == .orderedAscending + 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 { @@ -3026,10 +3672,7 @@ struct ContentView: View { guard commandPaletteListScope == .switcher else { return nil } if command.id.hasPrefix("switcher.workspace.") { - return CommandPaletteTrailingLabel(text: "Workspace", style: .kind) - } - if command.id.hasPrefix("switcher.surface.") { - return CommandPaletteTrailingLabel(text: "Surface", style: .kind) + return CommandPaletteTrailingLabel(text: String(localized: "commandPalette.kind.workspace", defaultValue: "Workspace"), style: .kind) } return nil } @@ -3040,22 +3683,15 @@ struct ContentView: View { var entries: [CommandPaletteCommand] = [] let estimatedCount = windowContexts.reduce(0) { partial, context in - partial + max(1, context.tabManager.tabs.count) * 4 + partial + context.tabManager.tabs.count } entries.reserveCapacity(estimatedCount) var nextRank = 0 for context in windowContexts { - var workspaces = context.tabManager.tabs + let workspaces = commandPaletteOrderedSwitcherWorkspaces(for: context) guard !workspaces.isEmpty else { continue } - 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) - } - let windowId = context.windowId let windowTabManager = context.tabManager let windowKeywords = commandPaletteWindowKeywords(windowLabel: context.windowLabel) @@ -3079,7 +3715,7 @@ struct ContentView: View { id: workspaceCommandId, rank: nextRank, title: workspaceName, - subtitle: commandPaletteSwitcherSubtitle(base: "Workspace", windowLabel: context.windowLabel), + subtitle: commandPaletteSwitcherSubtitle(base: String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace"), windowLabel: context.windowLabel), shortcutHint: nil, keywords: workspaceKeywords, dismissOnRun: true, @@ -3087,62 +3723,12 @@ struct ContentView: View { focusCommandPaletteSwitcherTarget( windowId: windowId, tabManager: windowTabManager, - workspaceId: workspaceId, - panelId: nil + workspaceId: workspaceId ) } ) ) nextRank += 1 - - var orderedPanelIds = workspace.sidebarOrderedPanelIds() - if let focusedPanelId = workspace.focusedPanelId, - let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) { - orderedPanelIds.remove(at: focusedIndex) - orderedPanelIds.insert(focusedPanelId, at: 0) - } - - for panelId in orderedPanelIds { - guard let panel = workspace.panels[panelId] else { continue } - let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle) - let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal" - let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords( - baseKeywords: [ - "tab", - "surface", - "panel", - "switch", - "go", - workspaceName, - panelTitle, - typeLabel.lowercased() - ] + windowKeywords, - metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId) - ) - entries.append( - CommandPaletteCommand( - id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())", - rank: nextRank, - title: panelTitle, - subtitle: commandPaletteSwitcherSubtitle( - base: "\(typeLabel) • \(workspaceName)", - windowLabel: context.windowLabel - ), - shortcutHint: nil, - keywords: panelKeywords, - dismissOnRun: true, - action: { - focusCommandPaletteSwitcherTarget( - windowId: windowId, - tabManager: windowTabManager, - workspaceId: workspaceId, - panelId: panelId - ) - } - ) - ) - nextRank += 1 - } } } @@ -3173,7 +3759,7 @@ struct ContentView: View { var windowLabelById: [UUID: String] = [:] if orderedSummaries.count > 1 { for (index, summary) in orderedSummaries.enumerated() where summary.windowId != windowId { - windowLabelById[summary.windowId] = "Window \(index + 1)" + windowLabelById[summary.windowId] = String(localized: "commandPalette.switcher.windowLabel", defaultValue: "Window \(index + 1)") } } @@ -3208,27 +3794,38 @@ struct ContentView: View { 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, - panelId: UUID? + 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) - if let panelId { - tabManager.focusTab(workspaceId, surfaceId: panelId, suppressFlash: true) - } else { - tabManager.focusTab(workspaceId, suppressFlash: true) - } + tabManager.focusTab(workspaceId, suppressFlash: true) } } private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata { - // Keep workspace rows coarse so surface rows win for directory/branch-specific queries. + // 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 @@ -3239,33 +3836,6 @@ struct ContentView: View { ) } - private func commandPalettePanelSearchMetadata(in workspace: Workspace, panelId: UUID) -> CommandPaletteSwitcherSearchMetadata { - var directories: [String] = [] - if let directory = workspace.panelDirectories[panelId] { - directories.append(directory) - } else if workspace.focusedPanelId == panelId { - directories.append(workspace.currentDirectory) - } - - var branches: [String] = [] - if let branch = workspace.panelGitBranches[panelId]?.branch { - branches.append(branch) - } else if workspace.focusedPanelId == panelId, let branch = workspace.gitBranch?.branch { - branches.append(branch) - } - - var ports = workspace.surfaceListeningPorts[panelId] ?? [] - if ports.isEmpty, workspace.panels.count == 1 { - ports = workspace.listeningPorts - } - - return CommandPaletteSwitcherSearchMetadata( - directories: directories, - branches: branches, - ports: ports - ) - } - private func commandPaletteCommands() -> [CommandPaletteCommand] { let context = commandPaletteContextSnapshot() let contributions = commandPaletteCommandContributions() @@ -3471,23 +4041,23 @@ struct ContentView: View { } func workspaceSubtitle(_ context: CommandPaletteContextSnapshot) -> String { - let name = context.string(CommandPaletteContextKeys.workspaceName) ?? "Workspace" - return "Workspace • \(name)" + 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) ?? "Tab" - return "Tab • \(name)" + 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) ?? "Tab" - return "Browser • \(name)" + 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) ?? "Tab" - return "Terminal • \(name)" + 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] = [] @@ -3495,24 +4065,24 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.newWorkspace", - title: constant("New Workspace"), - subtitle: constant("Workspace"), + 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("New Window"), - subtitle: constant("Window"), + 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("Shell Command: Install 'cmux' in PATH"), - subtitle: constant("CLI"), + 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) } ) @@ -3520,8 +4090,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.uninstallCLI", - title: constant("Shell Command: Uninstall 'cmux' from PATH"), - subtitle: constant("CLI"), + 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 } ) @@ -3529,16 +4099,16 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.openFolder", - title: constant("Open Folder…"), - subtitle: constant("Workspace"), + 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("New Tab (Terminal)"), - subtitle: constant("Tab"), + 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"] ) @@ -3546,8 +4116,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.newBrowserTab", - title: constant("New Tab (Browser)"), - subtitle: constant("Tab"), + 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"] ) @@ -3555,8 +4125,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.closeTab", - title: constant("Close Tab"), - subtitle: constant("Tab"), + title: constant(String(localized: "command.closeTab.title", defaultValue: "Close Tab")), + subtitle: constant(String(localized: "command.closeTab.subtitle", defaultValue: "Tab")), shortcutHint: "⌘W", keywords: ["close", "tab"] ) @@ -3564,8 +4134,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.closeWorkspace", - title: constant("Close Workspace"), - subtitle: constant("Workspace"), + title: constant(String(localized: "command.closeWorkspace.title", defaultValue: "Close Workspace")), + subtitle: constant(String(localized: "command.closeWorkspace.subtitle", defaultValue: "Workspace")), shortcutHint: "⌘⇧W", keywords: ["close", "workspace"] ) @@ -3573,24 +4143,24 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.closeWindow", - title: constant("Close Window"), - subtitle: constant("Window"), + 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("Toggle Full Screen"), - subtitle: constant("Window"), + 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("Reopen Closed Browser Tab"), - subtitle: constant("Browser"), + 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"] ) @@ -3598,40 +4168,40 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.toggleSidebar", - title: constant("Toggle Sidebar"), - subtitle: constant("Layout"), + 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("Flash Focused Panel"), - subtitle: constant("View"), + 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("Show Notifications"), - subtitle: constant("Notifications"), + 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("Jump to Latest Unread"), - subtitle: constant("Notifications"), + 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("Open Settings"), - subtitle: constant("Global"), + title: constant(String(localized: "command.openSettings.title", defaultValue: "Open Settings")), + subtitle: constant(String(localized: "command.openSettings.subtitle", defaultValue: "Global")), shortcutHint: "⌘,", keywords: ["settings", "preferences"] ) @@ -3639,16 +4209,16 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.checkForUpdates", - title: constant("Check for Updates"), - subtitle: constant("Global"), + 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("Apply Update (If Available)"), - subtitle: constant("Global"), + 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) } ) @@ -3656,16 +4226,16 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.attemptUpdate", - title: constant("Attempt Update"), - subtitle: constant("Global"), + 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("Restart CLI Listener"), - subtitle: constant("Global"), + 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"] ) ) @@ -3673,7 +4243,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.renameWorkspace", - title: constant("Rename Workspace…"), + title: constant(String(localized: "command.renameWorkspace.title", defaultValue: "Rename Workspace…")), subtitle: workspaceSubtitle, keywords: ["rename", "workspace", "title"], dismissOnRun: false, @@ -3683,7 +4253,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.clearWorkspaceName", - title: constant("Clear Workspace Name"), + title: constant(String(localized: "command.clearWorkspaceName.title", defaultValue: "Clear Workspace Name")), subtitle: workspaceSubtitle, keywords: ["clear", "workspace", "name"], when: { @@ -3696,7 +4266,7 @@ struct ContentView: View { CommandPaletteCommandContribution( commandId: "palette.toggleWorkspacePin", title: { context in - context.bool(CommandPaletteContextKeys.workspaceShouldPin) ? "Pin Workspace" : "Unpin Workspace" + 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"], @@ -3706,8 +4276,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.nextWorkspace", - title: constant("Next Workspace"), - subtitle: constant("Workspace Navigation"), + 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) } ) @@ -3715,8 +4285,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.previousWorkspace", - title: constant("Previous Workspace"), - subtitle: constant("Workspace Navigation"), + 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) } ) @@ -3725,7 +4295,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.renameTab", - title: constant("Rename Tab…"), + title: constant(String(localized: "command.renameTab.title", defaultValue: "Rename Tab…")), subtitle: panelSubtitle, keywords: ["rename", "tab", "title"], dismissOnRun: false, @@ -3735,7 +4305,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.clearTabName", - title: constant("Clear Tab Name"), + title: constant(String(localized: "command.clearTabName.title", defaultValue: "Clear Tab Name")), subtitle: panelSubtitle, keywords: ["clear", "tab", "name"], when: { @@ -3748,7 +4318,7 @@ struct ContentView: View { CommandPaletteCommandContribution( commandId: "palette.toggleTabPin", title: { context in - context.bool(CommandPaletteContextKeys.panelShouldPin) ? "Pin Tab" : "Unpin Tab" + 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"], @@ -3759,7 +4329,7 @@ struct ContentView: View { CommandPaletteCommandContribution( commandId: "palette.toggleTabUnread", title: { context in - context.bool(CommandPaletteContextKeys.panelHasUnread) ? "Mark Tab as Read" : "Mark Tab as Unread" + 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"], @@ -3769,8 +4339,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.nextTabInPane", - title: constant("Next Tab in Pane"), - subtitle: constant("Tab Navigation"), + 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) } ) @@ -3778,8 +4348,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.previousTabInPane", - title: constant("Previous Tab in Pane"), - subtitle: constant("Tab Navigation"), + 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) } ) @@ -3788,7 +4358,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.openWorkspacePullRequests", - title: constant("Open All Workspace PR Links"), + 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: { @@ -3800,7 +4370,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserBack", - title: constant("Back"), + title: constant(String(localized: "command.browserBack.title", defaultValue: "Back")), subtitle: browserPanelSubtitle, shortcutHint: "⌘[", keywords: ["browser", "back", "history"], @@ -3810,7 +4380,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserForward", - title: constant("Forward"), + title: constant(String(localized: "command.browserForward.title", defaultValue: "Forward")), subtitle: browserPanelSubtitle, shortcutHint: "⌘]", keywords: ["browser", "forward", "history"], @@ -3820,7 +4390,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserReload", - title: constant("Reload Page"), + title: constant(String(localized: "command.browserReload.title", defaultValue: "Reload Page")), subtitle: browserPanelSubtitle, shortcutHint: "⌘R", keywords: ["browser", "reload", "refresh"], @@ -3830,7 +4400,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserOpenDefault", - title: constant("Open Current Page in Default Browser"), + 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) } @@ -3839,7 +4409,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserFocusAddressBar", - title: constant("Focus Address Bar"), + title: constant(String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar")), subtitle: browserPanelSubtitle, shortcutHint: "⌘L", keywords: ["browser", "address", "omnibar", "url"], @@ -3849,7 +4419,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserToggleDevTools", - title: constant("Toggle Developer Tools"), + title: constant(String(localized: "command.browserToggleDevTools.title", defaultValue: "Toggle Developer Tools")), subtitle: browserPanelSubtitle, keywords: ["browser", "devtools", "inspector"], when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } @@ -3858,7 +4428,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserConsole", - title: constant("Show JavaScript Console"), + title: constant(String(localized: "command.browserConsole.title", defaultValue: "Show JavaScript Console")), subtitle: browserPanelSubtitle, keywords: ["browser", "console", "javascript"], when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } @@ -3867,7 +4437,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserZoomIn", - title: constant("Zoom In"), + title: constant(String(localized: "command.browserZoomIn.title", defaultValue: "Zoom In")), subtitle: browserPanelSubtitle, keywords: ["browser", "zoom", "in"], when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } @@ -3876,7 +4446,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserZoomOut", - title: constant("Zoom Out"), + title: constant(String(localized: "command.browserZoomOut.title", defaultValue: "Zoom Out")), subtitle: browserPanelSubtitle, keywords: ["browser", "zoom", "out"], when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } @@ -3885,7 +4455,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserZoomReset", - title: constant("Actual Size"), + title: constant(String(localized: "command.browserZoomReset.title", defaultValue: "Actual Size")), subtitle: browserPanelSubtitle, keywords: ["browser", "zoom", "reset", "actual size"], when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } @@ -3894,8 +4464,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserClearHistory", - title: constant("Clear Browser History"), - subtitle: constant("Browser"), + 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) } ) @@ -3903,8 +4473,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserSplitRight", - title: constant("Split Browser Right"), - subtitle: constant("Browser Layout"), + 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) } ) @@ -3912,8 +4482,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserSplitDown", - title: constant("Split Browser Down"), - subtitle: constant("Browser Layout"), + 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) } ) @@ -3921,8 +4491,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserDuplicateRight", - title: constant("Duplicate Browser to the Right"), - subtitle: constant("Browser Layout"), + 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) } ) @@ -3942,10 +4512,34 @@ struct ContentView: View { ) ) } + 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("Find…"), + title: constant(String(localized: "command.terminalFind.title", defaultValue: "Find…")), subtitle: terminalPanelSubtitle, shortcutHint: "⌘F", keywords: ["terminal", "find", "search"], @@ -3955,7 +4549,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalFindNext", - title: constant("Find Next"), + title: constant(String(localized: "command.terminalFindNext.title", defaultValue: "Find Next")), subtitle: terminalPanelSubtitle, shortcutHint: "⌘G", keywords: ["terminal", "find", "next", "search"], @@ -3965,7 +4559,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalFindPrevious", - title: constant("Find Previous"), + title: constant(String(localized: "command.terminalFindPrevious.title", defaultValue: "Find Previous")), subtitle: terminalPanelSubtitle, shortcutHint: "⌘⇧G", keywords: ["terminal", "find", "previous", "search"], @@ -3975,7 +4569,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalHideFind", - title: constant("Hide Find Bar"), + title: constant(String(localized: "command.terminalHideFind.title", defaultValue: "Hide Find Bar")), subtitle: terminalPanelSubtitle, shortcutHint: "⌘⇧F", keywords: ["terminal", "hide", "find", "search"], @@ -3985,7 +4579,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalUseSelectionForFind", - title: constant("Use Selection for Find"), + title: constant(String(localized: "command.terminalUseSelectionForFind.title", defaultValue: "Use Selection for Find")), subtitle: terminalPanelSubtitle, keywords: ["terminal", "selection", "find"], when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } @@ -3994,8 +4588,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalSplitRight", - title: constant("Split Right"), - subtitle: constant("Terminal Layout"), + 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) } ) @@ -4003,8 +4597,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalSplitDown", - title: constant("Split Down"), - subtitle: constant("Terminal Layout"), + 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) } ) @@ -4012,8 +4606,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalSplitBrowserRight", - title: constant("Split Browser Right"), - subtitle: constant("Terminal Layout"), + 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) } ) @@ -4021,8 +4615,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalSplitBrowserDown", - title: constant("Split Browser Down"), - subtitle: constant("Terminal Layout"), + 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) } ) @@ -4030,8 +4624,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.toggleSplitZoom", - title: constant("Toggle Pane Zoom"), - subtitle: constant("Terminal Layout"), + 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) && @@ -4042,7 +4636,7 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: "palette.equalizeSplits", - title: constant("Equalize Splits"), + title: constant(String(localized: "command.equalizeSplits.title", defaultValue: "Equalize Splits")), subtitle: workspaceSubtitle, keywords: ["split", "equalize", "balance", "divider", "layout"], when: { $0.bool(CommandPaletteContextKeys.workspaceHasSplits) } @@ -4063,8 +4657,8 @@ struct ContentView: View { panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false - panel.title = "Open Folder" - panel.prompt = "Open" + 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) } @@ -4286,6 +4880,14 @@ struct ContentView: View { } } } + registry.register(commandId: "palette.vscodeServeWebStop") { + stopInlineVSCodeServeWeb() + } + registry.register(commandId: "palette.vscodeServeWebRestart") { + if !restartInlineVSCodeServeWeb() { + NSSound.beep() + } + } registry.register(commandId: "palette.terminalFind") { tabManager.startSearch() } @@ -4342,7 +4944,7 @@ struct ContentView: View { return custom } let title = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) - return title.isEmpty ? "Workspace" : title + return title.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : title } private func panelDisplayName(workspace: Workspace, panelId: UUID, fallback: String) -> String { @@ -4351,7 +4953,7 @@ struct ContentView: View { return title } let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedFallback.isEmpty ? "Tab" : trimmedFallback + return trimmedFallback.isEmpty ? String(localized: "panel.displayName.fallback", defaultValue: "Tab") : trimmedFallback } private func commandPaletteSelectedIndex(resultCount: Int) -> Int { @@ -4359,6 +4961,116 @@ struct ContentView: View { 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 @@ -4398,14 +5110,34 @@ struct ContentView: View { } } + 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 = commandPaletteResults.count + 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() } @@ -4462,14 +5194,70 @@ struct ContentView: View { return .handled } - private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) { - let visibleResults = visibleResults ?? Array(commandPaletteResults) - guard !visibleResults.isEmpty else { - NSSound.beep() + 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 } - let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) - runCommandPaletteCommand(visibleResults[index].command) + 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) { @@ -4521,6 +5309,12 @@ struct ContentView: View { beginRenameWorkspaceFlow() } + private func presentFeedbackComposer() { + DispatchQueue.main.async { + isFeedbackComposerPresented = true + } + } + static func shouldHandleCommandPaletteRequest( observedWindow: NSWindow?, requestedWindow: NSWindow?, @@ -4551,7 +5345,7 @@ struct ContentView: View { private func syncCommandPaletteDebugStateForObservedWindow() { guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window) - let visibleResultCount = commandPaletteResults.count + let visibleResultCount = commandPaletteVisibleResults.count let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0 AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window) AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window) @@ -4570,7 +5364,7 @@ struct ContentView: View { mode = "rename_confirm" } - let rows = Array(commandPaletteResults.prefix(20)).map { result in + let rows = Array(commandPaletteVisibleResults.prefix(20)).map { result in CommandPaletteDebugResultRow( commandId: result.command.id, title: result.command.title, @@ -4612,26 +5406,53 @@ struct ContentView: View { commandPaletteQuery = initialQuery commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil + scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() } private func dismissCommandPalette(restoreFocus: Bool = true) { - let focusTarget = commandPaletteRestoreFocusTarget + 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) } @@ -4641,6 +5462,117 @@ struct ContentView: View { 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=\(clickedFocusTarget.intent == .browserAddressBar ? "addressBar" : "panel")" + ) + } 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, + intent: .panel + ) + } + + 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, + intent: .panel + ) + } + + 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, + intent: .panel + ) + } + + return nil + } + private func restoreCommandPaletteFocus( target: CommandPaletteRestoreFocusTarget, attemptsRemaining: Int @@ -4794,10 +5726,14 @@ struct ContentView: View { persistCommandPaletteUsageHistory(history) } - private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { - guard let entry = commandPaletteUsageHistoryByCommandId[commandId] else { return 0 } + 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 now = Date().timeIntervalSince1970 let ageDays = max(0, now - entry.lastUsedAt) / 86_400 let recencyBoost = max(0, 320 - Int(ageDays * 20)) let countBoost = min(180, entry.useCount * 12) @@ -4806,6 +5742,15 @@ struct ContentView: View { 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 beginRenameWorkspaceFlow() { guard let workspace = tabManager.selectedWorkspace else { NSSound.beep() @@ -4919,6 +5864,8 @@ struct ContentView: View { 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() @@ -4927,6 +5874,54 @@ struct ContentView: View { } } + 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 = { @@ -4959,7 +5954,7 @@ struct ContentView: View { #endif } -struct CommandPaletteSwitcherSearchMetadata { +struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { let directories: [String] let branches: [String] let ports: [Int] @@ -5078,23 +6073,78 @@ enum CommandPaletteSwitcherSearchIndexer { 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? { - let normalizedQuery = normalize(query) - guard !normalizedQuery.isEmpty else { return 0 } - let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - guard !tokens.isEmpty else { return 0 } + score( + preparedQuery: preparedQuery(query), + normalizedCandidates: candidates + .map(normalizeForSearch) + .filter { !$0.isEmpty } + ) + } - let normalizedCandidates = candidates - .map(normalize) - .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 tokens { + for token in preparedQuery.tokens { var bestTokenScore: Int? for candidate in normalizedCandidates { guard let candidateScore = scoreToken(token, in: candidate) else { continue } @@ -5107,19 +6157,19 @@ enum CommandPaletteFuzzyMatcher { } static func matchCharacterIndices(query: String, candidate: String) -> Set<Int> { - let normalizedQuery = normalize(query) - guard !normalizedQuery.isEmpty else { return [] } + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } - let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - guard !tokens.isEmpty else { return [] } + static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set<Int> { + guard !preparedQuery.isEmpty else { return [] } - let loweredCandidate = normalize(candidate) + let loweredCandidate = normalizeForSearch(candidate) guard !loweredCandidate.isEmpty else { return [] } let candidateChars = Array(loweredCandidate) var matched: Set<Int> = [] - for token in tokens { + for token in preparedQuery.tokens { if token == loweredCandidate { matched.formUnion(0..<candidateChars.count) continue @@ -5137,6 +6187,11 @@ enum CommandPaletteFuzzyMatcher { 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 @@ -5156,13 +6211,6 @@ enum CommandPaletteFuzzyMatcher { return matched } - private static func normalize(_ text: String) -> String { - text - .trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - } - private static func scoreToken(_ token: String, in candidate: String) -> Int? { guard !token.isEmpty else { return 0 } @@ -5184,6 +6232,12 @@ enum CommandPaletteFuzzyMatcher { 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) @@ -5244,6 +6298,35 @@ enum CommandPaletteFuzzyMatcher { 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) @@ -5278,9 +6361,10 @@ enum CommandPaletteFuzzyMatcher { candidateChars: [Character], candidateStart: Int ) -> Bool { - guard length > 0 else { return false } + 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 @@ -5411,6 +6495,180 @@ enum CommandPaletteFuzzyMatcher { 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 @@ -5525,6 +6783,121 @@ enum CommandPaletteFuzzyMatcher { } } +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? @@ -5540,11 +6913,12 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager @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? @@ -5572,7 +6946,7 @@ struct VerticalTabsSidebar: View { selection: $selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsCommandShortcutHints: commandKeyMonitor.isCommandPressed, + showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, dropIndicator: $dropIndicator @@ -5613,27 +6987,20 @@ struct VerticalTabsSidebar: View { .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( @@ -5642,7 +7009,7 @@ struct VerticalTabsSidebar: View { ) } .onDisappear { - commandKeyMonitor.stop() + modifierKeyMonitor.stop() dragAutoScrollController.stop() dragFailsafeMonitor.stop() draggedTabId = nil @@ -5686,11 +7053,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( @@ -5711,9 +7085,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, @@ -5731,6 +7106,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 @@ -5739,12 +7115,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 { @@ -5962,8 +7688,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? @@ -6057,7 +7783,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, @@ -6066,7 +7792,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, @@ -6081,31 +7807,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 } } @@ -6121,19 +7847,1013 @@ 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 +} + +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." + ) + } + } + } +} + +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.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: + 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() + } + } + + 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 @@ -6343,7 +9063,7 @@ private struct TabItemView: View { @Binding var selection: SidebarSelection @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? @@ -6446,7 +9166,7 @@ private struct TabItemView: View { } private var showCloseButton: Bool { - isHovering && tabManager.tabs.count > 1 && !(showsCommandShortcutHints || alwaysShowShortcutHints) + isHovering && tabManager.tabs.count > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints) } private var workspaceShortcutLabel: String? { @@ -6455,7 +9175,7 @@ private struct TabItemView: View { } private var showsWorkspaceShortcutHint: Bool { - (showsCommandShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil + (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil } private var workspaceHintSlotWidth: CGFloat { @@ -6486,6 +9206,10 @@ private struct TabItemView: View { } 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() @@ -6565,7 +9289,7 @@ private struct TabItemView: View { .foregroundColor(activeSecondaryColor(0.7)) } .buttonStyle(.plain) - .help(KeyboardShortcutSettings.Action.closeWorkspace.tooltip("Close Workspace")) + .safeHelp(KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip)) .frame(width: 16, height: 16, alignment: .center) .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) @@ -6587,7 +9311,7 @@ private struct TabItemView: View { .transition(.opacity) } } - .animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints) + .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) .frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing) } @@ -6738,7 +9462,7 @@ private struct TabItemView: View { .foregroundColor(pullRequestForegroundColor) } .buttonStyle(.plain) - .help("Open \(pullRequest.label) #\(pullRequest.number)") + .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) } } } @@ -6838,193 +9562,224 @@ 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 targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } - let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } - let reconnectLabel = remoteTargetWorkspaces.count > 1 ? "Reconnect Workspaces" : "Reconnect Workspace" - let disconnectLabel = remoteTargetWorkspaces.count > 1 ? "Disconnect Workspaces" : "Disconnect Workspace" - let tabColorPalette = WorkspaceTabColorSettings.palette() - 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" - 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() - } + .contextMenu { workspaceContextMenu } + } - if let key = renameWorkspaceShortcut.keyEquivalent { - Button("Rename Workspace…") { - promptRename() - } - .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) - } else { - Button("Rename Workspace…") { - promptRename() + 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 targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } + let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } + let reconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), + single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), + isMulti: isMulti) + let disconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), + single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), + isMulti: isMulti) + 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) } } - - if tab.hasCustomTitle { - Button("Remove Custom Workspace Name") { - tabManager.clearCustomTitle(tabId: tab.id) - } - } - - if !remoteTargetWorkspaces.isEmpty { - Divider() - - Button(reconnectLabel) { - for workspace in remoteTargetWorkspaces { - workspace.reconnectRemoteConnection() - } - } - .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting }) - - Button(disconnectLabel) { - for workspace in remoteTargetWorkspaces { - workspace.disconnectRemoteConnection(clearConfiguration: false) - } - } - .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) - } - - Menu("Workspace Color") { - if tab.customColor != nil { - Button { - applyTabColor(nil, targetIds: targetIds) - } label: { - Label("Clear Color", systemImage: "xmark.circle") - } - } - - Button { - promptCustomColor(targetIds: targetIds) - } label: { - Label("Choose Custom Color…", systemImage: "paintpalette") - } - - if !tabColorPalette.isEmpty { - Divider() - } - - 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))) - } - } - } - } - - if let copyableSidebarSSHError { - Button("Copy SSH Error") { - copyTextToPasteboard(copyableSidebarSSHError) - } - } - - Divider() - - Button("Move Up") { - moveBy(-1) - } - .disabled(index == 0) - - Button("Move Down") { - moveBy(1) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button("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 ? "Move Workspaces to Window" : "Move Workspace to Window" - Menu(moveMenuTitle) { - Button("New Window") { - moveWorkspacesToNewWindow(targetIds) - } - .disabled(targetIds.isEmpty) - - 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)) + 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(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { + tabManager.clearCustomTitle(tabId: tab.id) + } + } + + if !remoteTargetWorkspaces.isEmpty { + Divider() + + Button(reconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.reconnectRemoteConnection() + } + } + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting }) + + Button(disconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } + } + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) + } + + 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") + } + } + + Button { + promptCustomColor(targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") + } + + if !tabColorPalette.isEmpty { + Divider() + } + + 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))) + } + } + } + } + + if let copyableSidebarSSHError { + Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + + 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) + + 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(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 { @@ -7089,7 +9844,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 \(tabManager.tabs.count)") } private func moveBy(_ delta: Int) { @@ -7115,6 +9870,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) @@ -7137,6 +9893,12 @@ private struct TabItemView: View { lastSidebarSelectionIndex = index tabManager.selectTab(tab) + if wasSelected, !isCommand, !isShift { + tabManager.dismissNotificationOnDirectInteraction( + tabId: tab.id, + surfaceId: tabManager.focusedSurfaceId(for: tab.id) + ) + } selection = .tabs } @@ -7392,9 +10154,9 @@ private struct TabItemView: View { private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String { switch status { - case .open: return "open" - case .merged: return "merged" - case .closed: return "closed" + 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") } } @@ -7547,16 +10309,16 @@ private struct TabItemView: View { private func promptCustomColor(targetIds: [UUID]) { let alert = NSAlert() - alert.messageText = "Custom Workspace Color" - alert.informativeText = "Enter a hex color in the format #RRGGBB." + 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: "Apply") - alert.addButton(withTitle: "Cancel") + 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 @@ -7577,27 +10339,27 @@ private struct TabItemView: View { private func showInvalidColorAlert(_ value: String) { let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "Invalid Color" + alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - alert.informativeText = "Enter a hex color in the format #RRGGBB." + alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") } else { - alert.informativeText = "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB." + alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") } - alert.addButton(withTitle: "OK") + 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 { @@ -7625,7 +10387,7 @@ private struct SidebarMetadataRows: View { } if shouldShowToggle { - Button(isExpanded ? "Show less" : "Show more") { + Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { onFocus() withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() @@ -7637,7 +10399,7 @@ private struct SidebarMetadataRows: View { .frame(maxWidth: .infinity, alignment: .leading) } } - .help(helpText) + .safeHelp(helpText) } private var activeSecondaryTextColor: Color { @@ -7677,7 +10439,7 @@ private struct SidebarMetadataEntryRow: View { rowContent(underlined: true) } .buttonStyle(.plain) - .help(url.absoluteString) + .safeHelp(url.absoluteString) } else { rowContent(underlined: false) .contentShape(Rectangle()) @@ -7778,7 +10540,7 @@ private struct SidebarMetadataMarkdownBlocks: View { } if shouldShowToggle { - Button(isExpanded ? "Show less details" : "Show more details") { + 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() @@ -8471,7 +11233,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) } @@ -8636,7 +11398,7 @@ 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) @@ -8900,19 +11662,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") } } @@ -8948,8 +11710,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") } } @@ -8970,9 +11732,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") } } @@ -8997,12 +11759,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") } } @@ -9085,18 +11847,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..b7f874ea --- /dev/null +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -0,0 +1,193 @@ +import Bonsplit +import SwiftUI + +struct BrowserSearchOverlay: View { + let panelId: UUID + @ObservedObject var searchState: BrowserSearchState + let onNext: () -> Void + let onPrevious: () -> 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 + + private let padding: CGFloat = 8 + + private func requestSearchFieldFocus(maxAttempts: Int = 3) { + guard maxAttempts > 0 else { return } + isSearchFieldFocused = true + guard maxAttempts > 1 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + requestSearchFieldFocus(maxAttempts: maxAttempts - 1) + } + } + + var body: some View { + GeometryReader { geo in + HStack(spacing: 4) { + TextField("Search", text: $searchState.needle) + .textFieldStyle(.plain) + .accessibilityIdentifier("BrowserFindSearchTextField") + .frame(width: 180) + .padding(.leading, 8) + .padding(.trailing, 50) + .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 + requestSearchFieldFocus() + } + .onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in + guard let notifiedPanelId = notification.object as? UUID, + notifiedPanelId == panelId else { return } + DispatchQueue.main.async { + requestSearchFieldFocus() + } + } + .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 0900b2ce..aee272e9 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -1,33 +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 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)") @@ -43,20 +78,6 @@ struct SurfaceSearchOverlay: View { .padding(.trailing, 8) } } - .onExitCommand { - if searchState.needle.isEmpty { - onClose() - } else { - onMoveFocusToTerminal() - } - } - .backport.onKeyPress(.return) { modifiers in - let action = modifiers.contains(.shift) - ? "navigate_search:previous" - : "navigate_search:next" - onNavigateSearch(action) - return .handled - } Button(action: { #if DEBUG @@ -67,7 +88,7 @@ struct SurfaceSearchOverlay: View { Image(systemName: "chevron.up") } .buttonStyle(SearchButtonStyle()) - .help("Next match (Return)") + .safeHelp(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)")) Button(action: { #if DEBUG @@ -78,7 +99,7 @@ struct SurfaceSearchOverlay: View { 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 @@ -89,24 +110,18 @@ struct SurfaceSearchOverlay: View { 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=%@", tabId.uuidString, surfaceId.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 let focusedSurface = notification.object as? TerminalSurface, - focusedSurface.id == surfaceId else { return } - NSLog("Find: overlay focus tab=%@ surface=%@", tabId.uuidString, surfaceId.uuidString) - DispatchQueue.main.async { - isSearchFieldFocused = true - } - } .background( GeometryReader { barGeo in Color.clear.onAppear { @@ -185,6 +200,194 @@ 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)") + #endif + guard !alreadyFocused else { return } + window.makeFirstResponder(field) + } + + 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 a3516ae2..1e3aae49 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -21,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")! @@ -148,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 a49aab50..26168b24 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 { @@ -199,9 +267,20 @@ final class GhosttyDefaultBackgroundNotificationDispatcher { 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)) } @@ -209,24 +288,348 @@ 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? @@ -254,6 +657,7 @@ private final class GhosttySurfaceCallbackContext { 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] @@ -468,7 +872,13 @@ class GhosttyApp { 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) @@ -615,10 +1025,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? @@ -628,6 +1228,22 @@ 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 @@ -665,6 +1281,57 @@ class GhosttyApp { 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 @@ -723,6 +1390,7 @@ class GhosttyApp { 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 } @@ -747,9 +1415,16 @@ class GhosttyApp { config = newConfig lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) + scheduleSurfaceRefreshAfterConfigurationReload(source: source) logThemeAction("reload end source=\(source) soft=\(soft) mode=full") } + private func scheduleSurfaceRefreshAfterConfigurationReload(source: String) { + DispatchQueue.main.async { + AppDelegate.shared?.refreshTerminalSurfacesAfterGhosttyConfigReload(source: source) + } + } + func synchronizeThemeWithAppearance(_ appearance: NSAppearance?, source: String) { let currentColorScheme = GhosttyConfig.currentColorSchemePreference( appAppearance: appearance ?? NSApp?.effectiveAppearance @@ -831,6 +1506,7 @@ class GhosttyApp { 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, @@ -1344,20 +2020,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) } @@ -1365,22 +2069,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 } } } @@ -1391,11 +2134,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) @@ -1521,11 +2264,26 @@ final class TerminalSurface: Identifiable, ObservableObject { 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 @@ -1539,17 +2297,23 @@ 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, @@ -1607,6 +2371,165 @@ final class TerminalSurface: Identifiable, ObservableObject { return merged } + 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" @@ -1677,6 +2600,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())") @@ -1687,7 +2613,6 @@ final class TerminalSurface: Identifiable, ObservableObject { let s = surface { ghostty_surface_set_display_id(s, displayID) } - view.forceRefreshSurface() return } @@ -2001,6 +2926,7 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } + @discardableResult func updateSize( width: CGFloat, height: CGFloat, @@ -2008,15 +2934,15 @@ final class TerminalSurface: Identifiable, ObservableObject { yScale: CGFloat, layerScale: CGFloat, backingSize: CGSize? = nil - ) { - guard let surface = surface else { return } + ) -> Bool { + guard let surface = surface else { return false } _ = layerScale 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 } + guard wpx > 0, hpx > 0 else { return false } let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale) let sizeChanged = wpx != lastPixelWidth || hpx != lastPixelHeight @@ -2025,7 +2951,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 { @@ -2047,10 +2973,11 @@ 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() { + func forceRefresh(reason: String = "unspecified") { let hasSurface = surface != nil let viewState: String if let view = attachedView { @@ -2063,7 +2990,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } #if DEBUG let ts = ISO8601DateFormatter().string(from: Date()) - let line = "[\(ts)] forceRefresh: \(id) \(viewState)\n" + let line = "[\(ts)] forceRefresh: \(id) reason=\(reason) \(viewState)\n" let logPath = "/tmp/cmux-refresh-debug.log" if let handle = FileHandle(forWritingAtPath: logPath) { handle.seekToEndOfFile() @@ -2216,6 +3143,29 @@ 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) @@ -2240,6 +3190,8 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif deinit { + markPortalLifecycleClosed(reason: "deinit") + let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil @@ -2251,16 +3203,38 @@ final class TerminalSurface: Identifiable, ObservableObject { 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 } } } @@ -2279,6 +3253,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) { @@ -2301,6 +3277,27 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { 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" { @@ -2316,6 +3313,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var lastScrollEventTime: CFTimeInterval = 0 private var visibleInUI: Bool = true private var pendingSurfaceSize: CGSize? + private var lastDrawableSize: CGSize = .zero + private var isFindEscapeSuppressionArmed = false #if DEBUG private var lastSizeSkipSignature: String? #endif @@ -2379,8 +3378,10 @@ 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) @@ -2399,22 +3400,51 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + // 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 { - let signature = "\(cmuxShouldUseTransparentBackgroundWindow() ? "transparent" : color.hexString()):\(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 @@ -2422,7 +3452,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { 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=\(cmuxShouldUseTransparentBackgroundWindow()) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + "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))" ) } } @@ -2457,13 +3487,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() { @@ -2571,8 +3610,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return currentBounds } - private func updateSurfaceSize(size: CGSize? = nil) { - guard let terminalSurface = terminalSurface else { return } + private static func hasActiveTabDragPasteboard() -> Bool { + let types = NSPasteboard(name: .drag).types ?? [] + return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType) + } + + @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 @@ -2586,9 +3631,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } pendingSurfaceSize = size + guard !Self.hasActiveTabDragPasteboard() else { +#if DEBUG + let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))" + if lastSizeSkipSignature != signature { + dlog( + "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=tabDrag " + + "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))" @@ -2600,7 +3659,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } // First principles: derive pixel size from AppKit's backing conversion for the current @@ -2618,7 +3677,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } #if DEBUG if lastSizeSkipSignature != nil { @@ -2637,17 +3696,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { 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 = drawablePixelSize + 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, @@ -2655,15 +3726,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { layerScale: layerScale, backingSize: backingSize ) + 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() } @@ -2729,26 +3804,238 @@ 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() @@ -2757,6 +4044,73 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + // 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 { @@ -2858,6 +4212,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)? @@ -2886,6 +4243,13 @@ 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 { guard event.type == .keyDown else { return false } guard let fr = window?.firstResponder as? NSView, @@ -3012,6 +4376,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.keyDown(with: event) return } + if event.keyCode != 53 { + endFindEscapeSuppression() + } + if shouldConsumeSuppressedFindEscape(event) { + return + } + if handleKeyboardCopyModeIfNeeded(event, surface: surface) { + keyboardCopyModeConsumedKeyUps.insert(event.keyCode) + return + } #if DEBUG recordKeyLatency(path: "keyDown", event: event) #endif @@ -3035,7 +4409,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 @@ -3162,12 +4536,13 @@ 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 ?? [] + 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) { text.withCString { ptr in keyEvent.text = ptr @@ -3182,8 +4557,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // 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 { text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) @@ -3214,6 +4594,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.keyUp(with: event) return } + if event.keyCode != 53 { + endFindEscapeSuppression() + } + if shouldConsumeSuppressedFindEscape(event) { + endFindEscapeSuppression() + return + } + if event.keyCode == 53 { + endFindEscapeSuppression() + } + + 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 @@ -3262,6 +4656,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. @@ -3269,10 +4678,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 { @@ -3285,7 +4706,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 } @@ -3295,6 +4724,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 @@ -3345,12 +4785,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: @@ -3358,15 +4800,35 @@ 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 + 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 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)) @@ -3374,6 +4836,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)) } @@ -3603,12 +5068,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 } @@ -3779,6 +5254,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) @@ -3814,7 +5290,25 @@ private final class GhosttyFlashOverlayView: NSView { } } +private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + 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 @@ -3825,7 +5319,12 @@ 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 @@ -3835,8 +5334,26 @@ final class GhosttySurfaceScrollView: NSView { 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 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] = [:] @@ -3943,6 +5460,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) @@ -3953,6 +5496,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 @@ -3974,10 +5521,11 @@ final class GhosttySurfaceScrollView: NSView { 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 @@ -4024,6 +5572,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( @@ -4066,6 +5672,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, @@ -4080,6 +5700,13 @@ 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) } cancelFocusRequest() @@ -4098,36 +5725,50 @@ final class GhosttySurfaceScrollView: NSView { /// 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() } /// Request an immediate terminal redraw after geometry updates so stale IOSurface /// contents do not remain stretched during live resize churn. - func refreshSurfaceNow() { - surfaceView.terminalSurface?.forceRefresh() + func refreshSurfaceNow(reason: String = "portal.refreshSurfaceNow") { + surfaceView.terminalSurface?.forceRefresh(reason: reason) } - private func synchronizeGeometryAndContent() { + @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 - 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) + _ = setFrameIfNeeded( + dropZoneOverlayView, + to: dropZoneOverlayFrame(for: zone, in: bounds.size) + ) } if let pending = pendingDropZone, bounds.width > 2, @@ -4141,15 +5782,68 @@ 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() - synchronizeCoreSurface() + 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 + } + +#if DEBUG + private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } + + 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" + 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) " + + "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) } @@ -4160,7 +5854,12 @@ 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, @@ -4168,11 +5867,19 @@ final class GhosttySurfaceScrollView: NSView { queue: .main ) { [weak self] _ in 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() } @@ -4183,7 +5890,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)?) { @@ -4192,9 +5914,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() } @@ -4215,10 +5939,15 @@ 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() } @@ -4234,11 +5963,33 @@ final class GhosttySurfaceScrollView: NSView { // 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( @@ -4246,11 +5997,16 @@ final class GhosttySurfaceScrollView: NSView { 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() @@ -4269,9 +6025,14 @@ final class GhosttySurfaceScrollView: NSView { 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) @@ -4281,7 +6042,40 @@ final class GhosttySurfaceScrollView: NSView { 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 { @@ -4437,7 +6231,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif - func triggerFlash() { + func triggerFlash(style: FlashStyle = .standardFocus) { DispatchQueue.main.async { [weak self] in guard let self else { return } #if DEBUG @@ -4445,7 +6239,7 @@ 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") @@ -4555,7 +6349,9 @@ final class GhosttySurfaceScrollView: NSView { 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) searchState=\(searchActive ? "active" : "nil")") #endif let work = { [weak self] in guard let self else { return } @@ -4606,6 +6402,10 @@ final class GhosttySurfaceScrollView: NSView { return overlay.superview === self && !overlay.isHidden } + func debugHasKeyboardCopyModeIndicator() -> Bool { + keyboardCopyModeBadgeContainerView.superview === self && !keyboardCopyModeBadgeContainerView.isHidden + } + #endif /// Handle file/URL drops, forwarding to the terminal as shell-escaped paths. @@ -4701,7 +6501,6 @@ final class GhosttySurfaceScrollView: NSView { 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 { retry() @@ -4741,6 +6540,12 @@ final class GhosttySurfaceScrollView: NSView { return } + // Search focus restoration — only after confirming this is the active tab/pane. + if surfaceView.terminalSurface?.searchState != nil { + restoreSearchFocus(window: window) + return + } + if let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { return @@ -4780,27 +6585,87 @@ final class GhosttySurfaceScrollView: NSView { 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=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "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 surfaceView.terminalSurface?.searchState == nil else { return } guard let window, window.isKeyWindow else { 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) { 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) } + /// 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: + // 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") +#endif + case .terminal: + window.makeFirstResponder(surfaceView) +#if DEBUG + dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal") +#endif + } + } + + /// 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 } + current = v.superview + } + return false + } + #if DEBUG struct DebugRenderStats { let drawCount: Int @@ -5075,16 +6940,18 @@ final class GhosttySurfaceScrollView: NSView { private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect + guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return } 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. - private func synchronizeCoreSurface() { + @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 } - surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: 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. @@ -5114,17 +6981,27 @@ final class GhosttySurfaceScrollView: NSView { updateOverlayRingPath( layer: notificationRingLayer, bounds: notificationRingOverlayView.bounds, - inset: 2, - radius: 6 + inset: NotificationRingMetrics.inset, + radius: NotificationRingMetrics.cornerRadius ) } - private func updateFlashPath() { + 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: CGFloat(FocusFlashPattern.ringInset), - radius: CGFloat(FocusFlashPattern.ringCornerRadius) + inset: inset, + radius: radius ) } @@ -5144,19 +7021,30 @@ 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) { + scrollView.contentView.scroll(to: targetOrigin) + didChangeGeometry = true + } lastSentRow = Int(scrollbar.offset) } } - scrollView.reflectScrolledClipView(scrollView.contentView) + if didChangeGeometry { + scrollView.reflectScrolledClipView(scrollView.contentView) + } } private func handleScrollChange() { @@ -5332,8 +7220,6 @@ extension GhosttyNSView: NSTextInputClient { } func insertText(_ string: Any, replacementRange: NSRange) { - guard NSApp.currentEvent != nil else { return } - // Get the string value var chars = "" switch string { @@ -5348,6 +7234,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) @@ -5380,31 +7276,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() } } @@ -5417,6 +7339,7 @@ struct GhosttyTerminalView: NSViewRepresentable { var desiredPortalZPriority: Int = 0 var lastBoundHostId: ObjectIdentifier? var lastPaneDropZone: DropZone? + var lastSynchronizedHostGeometryRevision: UInt64 = 0 weak var hostedView: GhosttySurfaceScrollView? } @@ -5437,6 +7360,10 @@ struct GhosttyTerminalView: NSViewRepresentable { 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 } @@ -5478,17 +7405,32 @@ struct GhosttyTerminalView: NSViewRepresentable { } #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.setInactiveOverlay( - color: inactiveOverlayColor, - opacity: CGFloat(inactiveOverlayOpacity), - visible: showsInactiveOverlay - ) - hostedView.setNotificationRing(visible: showsUnreadNotificationRing) - hostedView.setSearchOverlay(searchState: searchState) 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 { @@ -5509,24 +7451,34 @@ struct GhosttyTerminalView: NSViewRepresentable { ) } #endif - hostedView.setDropZoneOverlay(zone: forwardedDropZone) + if hostOwnsPortalNow { + hostedView.setDropZoneOverlay(zone: forwardedDropZone) + } coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration - let hostContainer = 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) @@ -5534,9 +7486,16 @@ struct GhosttyTerminalView: NSViewRepresentable { 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 } + guard terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + inWindow: host.window != nil, + bounds: host.bounds, + reason: "geometryChanged" + ) else { return } + let hostId = ObjectIdentifier(host) if host.window != nil, - !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) { + (coordinator.lastBoundHostId != hostId || + !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)) { #if DEBUG dlog( "ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " + @@ -5548,35 +7507,55 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView: hostedView, to: host, visibleInUI: coordinator.desiredIsVisibleInUI, - zPriority: coordinator.desiredPortalZPriority + zPriority: coordinator.desiredPortalZPriority, + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration ) - coordinator.lastBoundHostId = ObjectIdentifier(host) + coordinator.lastBoundHostId = hostId hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } TerminalWindowPortalRegistry.synchronizeForAnchor(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } - if host.window != nil { + 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 + zPriority: coordinator.desiredPortalZPriority, + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = hostId + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + TerminalWindowPortalRegistry.synchronizeForAnchor(host) + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - } else { + } 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. @@ -5601,7 +7580,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let isBoundToCurrentHost = hostContainer.map { host in TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) } ?? true - let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate( + let shouldApplyImmediateHostedState = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate( hostedViewHasSuperview: hostedView.superview != nil, isBoundToCurrentHost: isBoundToCurrentHost ) @@ -5616,7 +7595,8 @@ struct GhosttyTerminalView: NSViewRepresentable { if desiredStateChanged { dlog( "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=staleHostBinding hostWindow=\(hostWindowAttached ? 1 : 0) " + + "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)" ) @@ -5654,6 +7634,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 index 392d0723..f7b7110a 100644 --- a/Sources/KeyboardLayout.swift +++ b/Sources/KeyboardLayout.swift @@ -1,3 +1,4 @@ +import AppKit import Carbon class KeyboardLayout { @@ -12,8 +13,12 @@ class KeyboardLayout { return nil } - /// Translate a physical keyCode to the unmodified character under the current keyboard layout. - static func character(forKeyCode keyCode: UInt16) -> String? { + /// Translate a physical keyCode to the character AppKit would use for shortcut matching, + /// preserving command-aware layouts such as "Dvorak - QWERTY Command". + static func character( + forKeyCode keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags = [] + ) -> String? { guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil @@ -31,7 +36,7 @@ class KeyboardLayout { keyboardLayout, keyCode, UInt16(kUCKeyActionDisplay), - 0, + translationModifierKeyState(for: modifierFlags), UInt32(LMGetKbdType()), UInt32(kUCKeyTranslateNoDeadKeysBit), &deadKeyState, @@ -43,4 +48,20 @@ class KeyboardLayout { guard status == noErr, length > 0 else { return nil } return String(utf16CodeUnits: chars, count: length).lowercased() } + + private static func translationModifierKeyState(for modifierFlags: NSEvent.ModifierFlags) -> UInt32 { + let normalized = modifierFlags + .intersection(.deviceIndependentFlagsMask) + .intersection([.shift, .command]) + + var carbonModifiers: Int = 0 + if normalized.contains(.shift) { + carbonModifiers |= shiftKey + } + if normalized.contains(.command) { + carbonModifiers |= cmdKey + } + + return UInt32((carbonModifiers >> 8) & 0xFF) + } } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 13095d90..f06c255b 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -10,6 +10,7 @@ enum KeyboardShortcutSettings { case newWindow case closeWindow case openFolder + case sendFeedback case showNotifications case jumpToUnread case triggerFlash @@ -23,6 +24,7 @@ enum KeyboardShortcutSettings { case renameWorkspace case closeWorkspace case newSurface + case toggleTerminalCopyMode // Panes / splits case focusLeft @@ -44,34 +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 .closeWindow: return "Close Window" - case .openFolder: return "Open Folder" - case .showNotifications: return "Show Notifications" - case .jumpToUnread: return "Jump to Latest Unread" - case .triggerFlash: return "Flash Focused Panel" - case .nextSurface: return "Next Surface" - case .prevSurface: return "Previous Surface" - case .nextSidebarTab: return "Next Workspace" - case .prevSidebarTab: return "Previous Workspace" - case .renameTab: return "Rename Tab" - case .renameWorkspace: return "Rename Workspace" - case .closeWorkspace: return "Close Workspace" - case .newSurface: return "New Surface" - case .focusLeft: return "Focus Pane Left" - case .focusRight: return "Focus Pane Right" - case .focusUp: return "Focus Pane Up" - case .focusDown: return "Focus Pane Down" - case .splitRight: return "Split Right" - case .splitDown: return "Split Down" - case .toggleSplitZoom: return "Toggle Pane Zoom" - case .splitBrowserRight: return "Split Browser Right" - case .splitBrowserDown: return "Split Browser Down" - case .openBrowser: return "Open Browser" - 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") } } @@ -82,6 +86,7 @@ enum KeyboardShortcutSettings { 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" @@ -102,6 +107,7 @@ enum KeyboardShortcutSettings { 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" @@ -120,6 +126,8 @@ enum KeyboardShortcutSettings { 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: @@ -160,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: @@ -469,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 53cc8737..096dce20 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -1,3 +1,4 @@ +import Bonsplit import SwiftUI struct NotificationsPage: View { @@ -67,7 +68,7 @@ struct NotificationsPage: View { private var header: some View { HStack { - Text("Notifications") + Text(String(localized: "notifications.title", defaultValue: "Notifications")) .font(.title2) .fontWeight(.semibold) @@ -76,7 +77,7 @@ struct NotificationsPage: View { if !notificationStore.notifications.isEmpty { jumpToUnreadButton - Button("Clear All") { + Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) { notificationStore.clearAll() } .buttonStyle(.bordered) @@ -91,9 +92,9 @@ 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) } @@ -107,25 +108,25 @@ struct NotificationsPage: View { AppDelegate.shared?.jumpToLatestUnread() }) { HStack(spacing: 6) { - Text("Jump to Latest Unread") + Text(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")) ShortcutAnnotation(text: jumpToUnreadShortcut.displayString) } } .buttonStyle(.bordered) .keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers) - .help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread")) + .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("Jump to Latest Unread") + Text(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")) ShortcutAnnotation(text: jumpToUnreadShortcut.displayString) } } .buttonStyle(.bordered) - .help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread")) + .safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))) .disabled(!hasUnreadNotifications) } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 4363ee92..aaad9f23 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3,18 +3,52 @@ import Combine import WebKit import AppKit import Bonsplit -import Network -struct BrowserProxyEndpoint: Equatable { - let host: String - let port: Int -} +enum GhosttyBackgroundTheme { + static func clampedOpacity(_ opacity: Double) -> CGFloat { + CGFloat(max(0.0, min(1.0, opacity))) + } -struct BrowserRemoteWorkspaceStatus: Equatable { - let target: String - let connectionState: WorkspaceRemoteConnectionState - let heartbeatCount: Int - let lastHeartbeatAt: Date? + 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 { @@ -91,11 +125,11 @@ enum BrowserThemeMode: String, CaseIterable, Identifiable { var displayName: String { switch self { case .system: - return "System" + return String(localized: "theme.system", defaultValue: "System") case .light: - return "Light" + return String(localized: "theme.light", defaultValue: "Light") case .dark: - return "Dark" + return String(localized: "theme.dark", defaultValue: "Dark") } } @@ -152,6 +186,8 @@ enum BrowserLinkOpenSettings { 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 { @@ -192,6 +228,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`). @@ -230,6 +298,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 { @@ -407,11 +488,35 @@ 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", @@ -1127,17 +1232,31 @@ 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() - private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" - private static let remoteLoopbackHosts: Set<String> = [ - "localhost", - "127.0.0.1", - "::1", - "0.0.0.0", - ] static let telemetryHookBootstrapScriptSource = """ (() => { @@ -1281,6 +1400,10 @@ final class BrowserPanel: Panel, ObservableObject { /// The underlying web view 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. private var suppressOmnibarAutofocusUntil: Date? @@ -1289,7 +1412,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? @@ -1312,9 +1658,6 @@ final class BrowserPanel: Panel, ObservableObject { /// Published loading state @Published private(set) var isLoading: Bool = false - /// Snapshot of remote SSH connection status for this panel's workspace. - @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? - /// Published download state for browser downloads (navigation + context menu). @Published private(set) var isDownloading: Bool = false @@ -1341,7 +1684,44 @@ final class BrowserPanel: Panel, ObservableObject { /// cleared only after BrowserPanelView acknowledges handling it. @Published private(set) var pendingAddressBarFocusRequestId: UUID? - private var cancellables = Set<AnyCancellable>() + /// Find-in-page state. Non-nil when the find bar is visible. + @Published var searchState: BrowserSearchState? = nil { + didSet { + if let searchState { + 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 + 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 var activePortalHostLease: PortalHostLease? + private var webViewCancellables = Set<AnyCancellable>() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? private var downloadDelegate: BrowserDownloadDelegate? @@ -1362,7 +1742,7 @@ final class BrowserPanel: Panel, ObservableObject { private let pageZoomStep: CGFloat = 0.1 private var insecureHTTPBypassHostOnce: String? private var insecureHTTPAlertFactory: () -> NSAlert - private var insecureHTTPAlertWindowProvider: () -> NSWindow? + private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } // Persist user intent across WebKit detach/reattach churn (split/layout updates). private var preferredDeveloperToolsVisible: Bool = false private var forceDeveloperToolsRefreshOnNextAttach: Bool = false @@ -1370,7 +1750,6 @@ final class BrowserPanel: Panel, ObservableObject { private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 private let developerToolsRestoreRetryMaxAttempts: Int = 40 - private var remoteProxyEndpoint: BrowserProxyEndpoint? private var browserThemeMode: BrowserThemeMode var displayTitle: String { @@ -1380,7 +1759,97 @@ 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 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 current.hostId == hostId { + activePortalHostLease = next + return true + } + + let currentUsable = Self.portalHostIsUsable(current) + let nextUsable = Self.portalHostIsUsable(next) + let shouldReplace = + current.paneId != paneId.id || + !currentUsable || + (nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio)) + + if shouldReplace { +#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))" + ) +#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 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? { @@ -1391,25 +1860,72 @@ final class BrowserPanel: Panel, ObservableObject { false } - init( - workspaceId: UUID, - initialURL: URL? = nil, - bypassInsecureHTTPHostOnce: String? = nil, - proxyEndpoint: BrowserProxyEndpoint? = nil - ) { + 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() + + // Enable developer extras (DevTools) + config.preferences.setValue(true, forKey: "developerExtrasEnabled") + + // 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 + ) + ) + + let webView = CmuxWebView(frame: .zero, configuration: config) + webView.allowsBackForwardNavigationGestures = true + 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 = 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) + } + + init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { self.id = UUID() self.workspaceId = workspaceId self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") - self.remoteProxyEndpoint = proxyEndpoint self.browserThemeMode = BrowserThemeSettings.mode() - let webView = Self.makeWebView(for: workspaceId) + let webView = Self.makeWebView() self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } - self.insecureHTTPAlertWindowProvider = { [weak webView] in - webView?.window ?? NSApp.keyWindow ?? NSApp.mainWindow - } - applyRemoteProxyConfigurationIfAvailable() // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() @@ -1418,6 +1934,8 @@ final class BrowserPanel: Panel, ObservableObject { Task { @MainActor [weak self] in 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 @@ -1428,6 +1946,8 @@ final class BrowserPanel: Panel, ObservableObject { 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 @@ -1439,6 +1959,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. @@ -1454,14 +1977,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) @@ -1473,12 +1988,13 @@ 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() + bindWebView(webView) applyBrowserThemeModeIfNeeded() + insecureHTTPAlertWindowProvider = { [weak self] in + self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } // Navigate to initial URL if provided if let url = initialURL { @@ -1512,173 +2028,7 @@ final class BrowserPanel: Panel, ObservableObject { } func updateWorkspaceId(_ newWorkspaceId: UUID) { - guard workspaceId != newWorkspaceId else { return } workspaceId = newWorkspaceId - rebindWebViewDataStoreIfNeeded() - } - - func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { - guard remoteProxyEndpoint != endpoint else { return } - remoteProxyEndpoint = endpoint - applyRemoteProxyConfigurationIfAvailable() - } - - func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { - guard remoteWorkspaceStatus != status else { return } - remoteWorkspaceStatus = status - } - - private func applyRemoteProxyConfigurationIfAvailable() { - guard #available(macOS 14.0, *) else { return } - - let store = webView.configuration.websiteDataStore - guard let endpoint = remoteProxyEndpoint else { - store.proxyConfigurations = [] - return - } - - let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty, - endpoint.port > 0 && endpoint.port <= 65535, - let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else { - store.proxyConfigurations = [] - return - } - - let nwEndpoint = NWEndpoint.hostPort( - host: NWEndpoint.Host(host), - port: nwPort - ) - // Prefer SOCKSv5; keep CONNECT configured as fallback. - let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint) - let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint) - store.proxyConfigurations = [socks, connect] - } - - private static func makeWebView(for workspaceId: UUID) -> CmuxWebView { - let config = WKWebViewConfiguration() - config.processPool = BrowserPanel.sharedProcessPool - // Keep data-store scoping at workspace granularity so remote proxy settings - // do not leak into local workspaces. - if #available(macOS 14.0, *) { - config.websiteDataStore = WKWebsiteDataStore(forIdentifier: workspaceId) - } else { - config.websiteDataStore = .default() - } - - // Enable developer extras (DevTools) - config.preferences.setValue(true, forKey: "developerExtrasEnabled") - - // 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 - ) - ) - - 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() - - // Always present as Safari. - webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent - return webView - } - - private func rebindWebViewDataStoreIfNeeded() { - guard #available(macOS 14.0, *) else { return } - - let oldWebView = webView - let restoreURL = oldWebView.url ?? currentURL - let restorePageZoom = oldWebView.pageZoom - let shouldRestoreNavigation = shouldRenderWebView - && restoreURL?.absoluteString != blankURLString - - oldWebView.stopLoading() - oldWebView.navigationDelegate = nil - oldWebView.uiDelegate = nil - if let oldCmuxWebView = oldWebView as? CmuxWebView { - oldCmuxWebView.onContextMenuDownloadStateChanged = nil - } - BrowserWindowPortalRegistry.detach(webView: oldWebView) - oldWebView.removeFromSuperview() - - webViewObservers.removeAll() - cancellables.removeAll() - - let replacement = Self.makeWebView(for: workspaceId) - replacement.pageZoom = restorePageZoom - replacement.navigationDelegate = navigationDelegate - replacement.uiDelegate = uiDelegate - replacement.onContextMenuDownloadStateChanged = { [weak self] downloading in - if downloading { - self?.beginDownloadActivity() - } else { - self?.endDownloadActivity() - } - } - - objectWillChange.send() - webView = replacement - insecureHTTPAlertWindowProvider = { [weak self] in - self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow - } - nativeCanGoBack = false - nativeCanGoForward = false - estimatedProgress = 0 - refreshNavigationAvailability() - setupObservers() - applyRemoteProxyConfigurationIfAvailable() - - if shouldRestoreNavigation, let restoreURL { - replacement.load(preparedNavigationRequest(URLRequest(url: restoreURL))) - } - } - - private func rewriteLoopbackURLForRemoteProxyIfNeeded(_ url: URL) -> URL { - guard remoteProxyEndpoint != nil else { return url } - guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return url } - guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url } - guard Self.remoteLoopbackHosts.contains(host) else { return url } - - // WebKit bypasses proxy settings for loopback hosts. Rewrite to a hostname - // that still resolves to 127.0.0.1 so requests go through the per-workspace - // SOCKS/CONNECT proxy endpoint instead of direct local dial. - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - components?.host = Self.remoteLoopbackProxyAliasHost - return components?.url ?? url - } - - private func canonicalLoopbackURLForDisplayIfNeeded(_ url: URL) -> URL { - guard remoteProxyEndpoint != nil else { return url } - guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url } - guard host == Self.remoteLoopbackProxyAliasHost else { return url } - - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - components?.host = "localhost" - return components?.url ?? url - } - - private func preparedNavigationRequest(_ request: URLRequest) -> URLRequest { - var prepared = browserPreparedNavigationRequest(request) - guard let url = prepared.url else { return prepared } - let rewrittenURL = rewriteLoopbackURLForRemoteProxyIfNeeded(url) - if rewrittenURL != url { - prepared.url = rewrittenURL - } - return prepared } func triggerFlash() { @@ -1722,7 +2072,7 @@ final class BrowserPanel: Panel, ObservableObject { refreshNavigationAvailability() } - private func setupObservers() { + private func setupObservers(for webView: WKWebView) { // URL changes let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in Task { @MainActor in @@ -1783,11 +2133,87 @@ 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() + 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 + webView = replacement + webViewInstanceID = UUID() + shouldRenderWebView = wasRenderable + + bindWebView(replacement) + + 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() { @@ -1828,7 +2254,7 @@ final class BrowserPanel: Panel, ObservableObject { navigationDelegate = nil uiDelegate = nil webViewObservers.removeAll() - cancellables.removeAll() + webViewCancellables.removeAll() faviconTask?.cancel() faviconTask = nil } @@ -2062,7 +2488,7 @@ final class BrowserPanel: Panel, ObservableObject { BrowserHistoryStore.shared.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url - webView.load(preparedNavigationRequest(request)) + browserLoadRequest(request, in: webView) } /// Navigate with smart URL/search detection @@ -2117,17 +2543,13 @@ final class BrowserPanel: Panel, ObservableObject { 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 handleResponse: (NSApplication.ModalResponse) -> Void = { [weak self, weak alert] response in self?.handleInsecureHTTPAlertResponse( @@ -2188,7 +2610,7 @@ final class BrowserPanel: Panel, ObservableObject { BrowserWindowPortalRegistry.detach(webView: webView) } webViewObservers.removeAll() - cancellables.removeAll() + webViewCancellables.removeAll() } } @@ -2208,6 +2630,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 } @@ -2281,13 +2706,16 @@ extension BrowserPanel { "bypass=\(bypassInsecureHTTPHostOnce ?? "nil")" ) #endif - guard let tabManager = AppDelegate.shared?.tabManager else { + guard let app = AppDelegate.shared else { #if DEBUG - dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingTabManager") + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingAppDelegate") #endif return } - guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + 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 @@ -2498,7 +2926,7 @@ extension BrowserPanel { /// 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) { @@ -2546,25 +2974,134 @@ extension BrowserPanel { try await webView.evaluateJavaScript(script) } + // MARK: - Find in Page + + func startFind() { + if searchState == nil { + searchState = BrowserSearchState() + } + postBrowserSearchFocusNotification() + // Focus notification can race with portal overlay mount. Re-post on the + // next runloop and shortly after so the find field can claim first responder. + DispatchQueue.main.async { [weak self] in + self?.postBrowserSearchFocusNotification() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.postBrowserSearchFocusNotification() + } + } + + private func postBrowserSearchFocusNotification() { + NotificationCenter.default.post(name: .browserSearchFocus, object: id) + } + + 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() { + searchState = nil + } + + private func restoreFindStateAfterNavigation(replaySearch: Bool) { + guard let state = searchState else { return } + state.total = nil + state.selected = nil + if replaySearch, !state.needle.isEmpty { + executeFindSearch(state.needle) + } + postBrowserSearchFocusNotification() + } + + private func executeFindSearch(_ needle: String) { + guard !needle.isEmpty else { + executeFindClear() + 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 { @@ -2578,6 +3115,9 @@ extension BrowserPanel { if suppressWebViewFocusForAddressBar { return true } + if searchState != nil { + return true + } if let until = suppressWebViewFocusUntil { return Date() < until } @@ -2585,12 +3125,17 @@ extension BrowserPanel { } func beginSuppressWebViewFocusForAddressBar() { - if !suppressWebViewFocusForAddressBar { + 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() { @@ -2606,33 +3151,188 @@ extension BrowserPanel { func requestAddressBarFocus() -> UUID { 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 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. /// `currentURL` can lag behind navigation changes, so prefer the live WKWebView URL. func preferredURLStringForOmnibar() -> String? { - if let webViewURL = webView.url - .map(canonicalLoopbackURLForDisplayIfNeeded)? - .absoluteString + if let webViewURL = webView.url?.absoluteString .trimmingCharacters(in: .whitespacesAndNewlines), !webViewURL.isEmpty, webViewURL != blankURLString { return webViewURL } - if let current = currentURL - .map(canonicalLoopbackURLForDisplayIfNeeded)? - .absoluteString + if let current = currentURL?.absoluteString .trimmingCharacters(in: .whitespacesAndNewlines), !current.isEmpty, current != blankURLString { @@ -2797,8 +3497,8 @@ extension BrowserPanel { func resetInsecureHTTPAlertHooksForTesting() { insecureHTTPAlertFactory = { NSAlert() } - insecureHTTPAlertWindowProvider = { [weak weakWebView = self.webView] in - weakWebView?.window ?? NSApp.keyWindow ?? NSApp.mainWindow + insecureHTTPAlertWindowProvider = { [weak self] in + self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow } } @@ -2863,6 +3563,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 @@ -2887,6 +3588,71 @@ 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 { @@ -3081,6 +3847,7 @@ func browserNavigationShouldOpenInNewTab( 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)? @@ -3129,9 +3896,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) { @@ -3142,29 +3929,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> @@ -3200,10 +3998,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> @@ -3240,7 +4038,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { navigationAction.targetFrame?.isMainFrame != false, shouldBlockInsecureHTTPNavigation?(url) == true { let intent: BrowserInsecureHTTPNavigationIntent - if shouldOpenInNewTab { + if shouldOpenInNewTab || navigationAction.targetFrame == nil { intent = .newTab } else { intent = .currentTab @@ -3283,14 +4081,13 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } - // target=_blank or window.open() without explicit new-tab intent — navigate in-place. + // target=_blank or window.open() — open in a new tab. if navigationAction.targetFrame == nil, - navigationAction.request.url != nil { + let url = navigationAction.request.url { #if DEBUG - let targetURL = navigationAction.request.url?.absoluteString ?? "nil" - dlog("browser.nav.decidePolicy.action kind=loadInPlaceFromNilTarget url=\(targetURL)") + dlog("browser.nav.decidePolicy.action kind=openInNewTabFromNilTarget url=\(url.absoluteString)") #endif - webView.load(navigationAction.request) + openInNewTab?(url) decisionHandler(.cancel) return } @@ -3379,9 +4176,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( @@ -3397,20 +4194,16 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } /// Returning nil tells WebKit not to open a new window. - /// Cmd+click and middle-click open 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? { - let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView) - let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( - navigationType: navigationAction.navigationType, - modifierFlags: navigationAction.modifierFlags, - buttonNumber: navigationAction.buttonNumber, - hasRecentMiddleClickIntent: hasRecentMiddleClickIntent - ) + // 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" @@ -3419,8 +4212,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + - "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + - "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" + "openInNewTab=1" ) #endif if let url = navigationAction.request.url { @@ -3435,25 +4227,19 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { return nil } if let requestNavigation { - let intent: BrowserInsecureHTTPNavigationIntent = - shouldOpenInNewTab ? .newTab : .currentTab + let intent: BrowserInsecureHTTPNavigationIntent = .newTab #if DEBUG dlog( - "browser.nav.createWebView.action kind=requestNavigation intent=\(intent == .newTab ? "newTab" : "currentTab") " + + "browser.nav.createWebView.action kind=requestNavigation intent=newTab " + "url=\(url.absoluteString)" ) #endif requestNavigation(navigationAction.request, intent) - } else if shouldOpenInNewTab { + } else { #if DEBUG dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)") #endif openInNewTab?(url) - } else { -#if DEBUG - dlog("browser.nav.createWebView.action kind=loadInPlace url=\(url.absoluteString)") -#endif - webView.load(navigationAction.request) } } return nil @@ -3475,6 +4261,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, @@ -3485,7 +4281,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() } } @@ -3499,8 +4295,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) } @@ -3517,8 +4313,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 82228842..98ae7485 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -206,11 +206,13 @@ func resolvedBrowserOmnibarPillBackgroundColor( /// 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 @@ -229,8 +231,12 @@ struct BrowserPanelView: View { @State private var focusFlashOpacity: Double = 0.0 @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero + @State private var addressBarHeight: CGFloat = 0 @State private var lastHandledAddressBarFocusRequestId: UUID? + @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 @@ -275,20 +281,31 @@ struct BrowserPanelView: View { BrowserThemeSettings.mode(for: browserThemeModeRaw) } + private var browserChromeBackground: Color { + _ = ghosttyBackgroundGeneration + return Color(nsColor: GhosttyBackgroundTheme.currentColor()) + } + private var browserChromeBackgroundColor: NSColor { - resolvedBrowserChromeBackgroundColor( + _ = ghosttyBackgroundGeneration + return resolvedBrowserChromeBackgroundColor( for: colorScheme, - themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() ) } private var browserChromeColorScheme: ColorScheme { - resolvedBrowserChromeColorScheme( + _ = ghosttyBackgroundGeneration + return resolvedBrowserChromeColorScheme( for: colorScheme, - themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() ) } + private var browserContentAccessibilityIdentifier: String { + "BrowserPanelContent.\(panel.id.uuidString)" + } + private var omnibarPillBackgroundColor: NSColor { resolvedBrowserOmnibarPillBackgroundColor( for: browserChromeColorScheme, @@ -296,11 +313,37 @@ struct BrowserPanelView: View { ) } + private var isCurrentPaneOwner: Bool { + guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }), + let currentPaneId = workspace.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 { + // 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, + onNext: { panel.findNext() }, + onPrevious: { panel.findPrevious() }, + onClose: { panel.hideFind() } + ) + } + } .overlay { RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) @@ -334,6 +377,9 @@ struct BrowserPanelView: View { .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 } @@ -346,7 +392,15 @@ struct BrowserPanelView: View { "addressFocused=\(addressBarFocused ? 1 : 0)" ) #endif - onRequestPanelFocus() + if addressBarFocused { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.webViewClickBlur") +#endif + setAddressBarFocused(false, reason: "webView.clickIntent") + } + if !isFocused { + onRequestPanelFocus() + } } .onAppear { UserDefaults.standard.register(defaults: [ @@ -366,6 +420,9 @@ struct BrowserPanelView: View { autoFocusOmnibarIfBlank() syncWebViewResponderPolicyWithViewState(reason: "onAppear") BrowserHistoryStore.shared.loadIfNeeded() +#if DEBUG + logBrowserFocusState(event: "view.onAppear") +#endif } .onChange(of: panel.focusFlashToken) { _ in triggerFocusFlashAnimation() @@ -379,7 +436,7 @@ struct BrowserPanelView: View { !panel.shouldSuppressWebViewFocus(), addressWasEmpty, !isWebViewBlank() { - addressBarFocused = false + setAddressBarFocused(false, reason: "panel.currentURL.loaded") } } .onChange(of: browserThemeModeRaw) { _ in @@ -396,17 +453,30 @@ 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") } .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() @@ -414,6 +484,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)) @@ -433,11 +506,17 @@ 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() @@ -451,9 +530,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 { @@ -471,7 +556,16 @@ struct BrowserPanelView: View { } .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) @@ -493,7 +587,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 @@ -509,7 +603,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 { @@ -530,18 +624,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")) } } } @@ -559,7 +653,7 @@ struct BrowserPanelView: View { } .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - .help(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip("Toggle Developer Tools")) + .safeHelp(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip(String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools"))) .accessibilityIdentifier("BrowserToggleDevToolsButton") } @@ -579,7 +673,7 @@ struct BrowserPanelView: View { .popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) { browserThemeModePopover } - .help("Browser Theme: \(browserThemeMode.displayName)") + .safeHelp("Browser Theme: \(browserThemeMode.displayName)") .accessibilityIdentifier("BrowserThemeModeButton") } @@ -640,7 +734,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() }, @@ -651,14 +745,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 } @@ -712,118 +806,57 @@ struct BrowserPanelView: View { if panel.shouldRenderWebView { WebViewRepresentable( panel: panel, - shouldAttachWebView: isVisibleInUI, + paneId: paneId, + shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner, shouldFocusWebView: isFocused && !addressBarFocused, isPanelFocused: isFocused, - portalZPriority: portalPriority + portalZPriority: portalPriority, + paneDropZone: paneDropZone, + searchOverlay: panel.searchState.map { searchState in + BrowserPortalSearchOverlayConfiguration( + panelId: panel.id, + searchState: searchState, + onNext: { panel.findNext() }, + onPrevious: { panel.findPrevious() }, + onClose: { panel.hideFind() } + ) + }, + 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 { - ZStack(alignment: .topLeading) { - Color(nsColor: browserChromeBackgroundColor) - if let status = panel.remoteWorkspaceStatus { - remoteWorkspaceBlankStateIndicator(status) - .padding(.top, 12) - .padding(.leading, 12) - .allowsHitTesting(false) + Color(nsColor: browserChromeBackgroundColor) + .contentShape(Rectangle()) + .accessibilityIdentifier(browserContentAccessibilityIdentifier) + .onTapGesture { + onRequestPanelFocus() + if addressBarFocused { + setAddressBarFocused(false, reason: "placeholderContent.tapBlur") + } } - } - .contentShape(Rectangle()) - .onTapGesture { - onRequestPanelFocus() - if addressBarFocused { - addressBarFocused = false - } - } } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .layoutPriority(1) .zIndex(0) } - @ViewBuilder - private func remoteWorkspaceBlankStateIndicator(_ status: BrowserRemoteWorkspaceStatus) -> some View { - HStack(spacing: 8) { - Image(systemName: remoteStatusSymbolName(status.connectionState)) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(remoteStatusColor(status.connectionState)) - - VStack(alignment: .leading, spacing: 1) { - Text("SSH \(status.target)") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Color.primary.opacity(0.92)) - .lineLimit(1) - Text(remoteStatusDetailText(status)) - .font(.system(size: 10, weight: .regular)) - .foregroundStyle(Color.primary.opacity(0.72)) - .lineLimit(1) - } - .fixedSize(horizontal: true, vertical: false) - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.85)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.primary.opacity(0.12), lineWidth: 1) - ) - } - } - - private func remoteStatusSymbolName(_ state: WorkspaceRemoteConnectionState) -> String { - switch state { - case .connected: - return "network" - case .connecting: - return "arrow.triangle.2.circlepath" - case .error: - return "exclamationmark.triangle.fill" - case .disconnected: - return "network.slash" - } - } - - private func remoteStatusColor(_ state: WorkspaceRemoteConnectionState) -> Color { - switch state { - case .connected: - return .green - case .connecting: - return .orange - case .error: - return .red - case .disconnected: - return .secondary - } - } - - private func remoteStatusDetailText(_ status: BrowserRemoteWorkspaceStatus) -> String { - switch status.connectionState { - case .connected: - guard let lastHeartbeat = status.lastHeartbeatAt else { - return "Connected, waiting for heartbeat" - } - let ageSeconds = max(0, Int(Date().timeIntervalSince(lastHeartbeat))) - return "Connected, heartbeat #\(status.heartbeatCount) \(ageSeconds)s ago" - case .connecting: - return "Connecting..." - case .error: - return "Connection error" - case .disconnected: - return "Disconnected" - } - } - private func triggerFocusFlashAnimation() { focusFlashAnimationGeneration &+= 1 let generation = focusFlashAnimationGeneration @@ -863,6 +896,82 @@ struct BrowserPanelView: View { 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 + } + + 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)) @@ -892,12 +1001,57 @@ struct BrowserPanelView: View { 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 !isCommandPaletteVisibleForPanelWindow() 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 @@ -906,11 +1060,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 } /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. @@ -920,15 +1092,48 @@ struct BrowserPanelView: View { } private func autoFocusOmnibarIfBlank() { - guard isFocused else { return } - guard !addressBarFocused else { return } - guard !isCommandPaletteVisibleForPanelWindow() 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 openDevTools() { @@ -948,13 +1153,15 @@ 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 +#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() { @@ -985,7 +1192,7 @@ struct BrowserPanelView: View { hideSuggestions() inlineCompletion = nil suppressNextFocusLostRevert = true - addressBarFocused = false + setAddressBarFocused(false, reason: "suggestion.commit") } private func handleOmnibarEscape() { @@ -1286,14 +1493,58 @@ 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 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 + } + let hasWebViewResponder = + browserFocusResponderChainContains(window.firstResponder, target: panel.webView) + if !hasWebViewResponder { + let fallbackFocusedWebView = window.makeFirstResponder(panel.webView) +#if DEBUG + dlog( + "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + + "fallbackFocusedWebView=\(fallbackFocusedWebView ? 1 : 0) " + + "restored=\(restored ? 1 : 0)" + ) +#endif + } + NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) + } } } } @@ -1993,6 +2244,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 { @@ -2217,7 +2476,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 } @@ -2298,15 +2557,17 @@ struct OmnibarSuggestion: Identifiable, Hashable { } func browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: Bool, + desiredOmnibarFocus: Bool, nextResponderIsOtherTextField: Bool ) -> Bool { - suppressWebViewFocus && !nextResponderIsOtherTextField + 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) @@ -2324,7 +2585,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?() @@ -2332,39 +2597,78 @@ 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) { + // 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 { return } @@ -2372,6 +2676,10 @@ private final class OmnibarNativeTextField: NSTextField { } override func performKeyEquivalent(with event: NSEvent) -> Bool { + shiftClickAnchor = nil + if (currentEditor() as? NSTextView)?.hasMarkedText() == true { + return super.performKeyEquivalent(with: event) + } if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true { return true } @@ -2410,6 +2718,35 @@ 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) @@ -2432,16 +2769,77 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { 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( - suppressWebViewFocus: parent.shouldSuppressWebViewFocus(), + 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 } } @@ -2450,16 +2848,33 @@ 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 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 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 } @@ -2470,11 +2885,21 @@ 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() @@ -2686,7 +3111,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 @@ -2703,34 +3128,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( @@ -3047,68 +3510,419 @@ private struct OmnibarSuggestionsView: View { .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 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 { var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? + private(set) var geometryRevision: UInt64 = 0 + private var lastReportedGeometryState: GeometryState? + private struct HostedInspectorDividerHit { + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + } + + 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 initialWindowX: CGFloat + let initialPageFrame: NSRect + let initialInspectorFrame: NSRect + } + + private enum DividerCursorKind: Equatable { + case vertical + + var cursor: NSCursor { .resizeLeftRight } + } + + private static let hostedInspectorDividerHitExpansion: CGFloat = 6 + private static let minimumHostedInspectorWidth: CGFloat = 120 + private var trackingArea: NSTrackingArea? + private var activeDividerCursorKind: DividerCursorKind? + private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? + private var preferredHostedInspectorWidth: CGFloat? + private var isApplyingHostedInspectorLayout = false +#if DEBUG + private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)? + private var hasLoggedMissingHostedInspectorCandidate = false +#endif + + 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 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 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() + if window == nil { + clearActiveDividerCursor(restoreArrow: false) + } else { + reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToWindow") + } + window?.invalidateCursorRects(for: self) onDidMoveToWindow?() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow") +#endif } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() - onGeometryChanged?() + reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToSuperview") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview") +#endif } override func layout() { super.layout() - onGeometryChanged?() + reapplyHostedInspectorDividerIfNeeded(reason: "layout") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "layout") +#endif } override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) - onGeometryChanged?() + window?.invalidateCursorRects(for: self) + reapplyHostedInspectorDividerIfNeeded(reason: "setFrameOrigin") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin") +#endif } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) - onGeometryChanged?() + window?.invalidateCursorRects(for: self) + reapplyHostedInspectorDividerIfNeeded(reason: "setFrameSize") + 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 { + if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) { +#if DEBUG + debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit) +#endif + return nativeHit + } +#if DEBUG + debugLogHitTest(stage: "hitTest.hostedInspectorManual", point: point, passThrough: false, hitView: hostedInspectorHit.inspectorView) +#endif + return self + } + 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) else { + super.mouseDown(with: event) + return + } + + hostedInspectorDividerDrag = HostedInspectorDividerDragState( + containerView: hostedInspectorHit.containerView, + pageView: hostedInspectorHit.pageView, + inspectorView: hostedInspectorHit.inspectorView, + 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 = min( + Self.minimumHostedInspectorWidth, + max(60, dragState.initialInspectorFrame.width) + ) + let minDividerX = max(containerBounds.minX, dragState.initialPageFrame.minX) + let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth) + let proposedDividerX = dragState.initialInspectorFrame.minX + (event.locationInWindow.x - dragState.initialWindowX) + let clampedDividerX = max(minDividerX, min(maxDividerX, proposedDividerX)) + let inspectorWidth = max(0, containerBounds.maxX - clampedDividerX) + preferredHostedInspectorWidth = inspectorWidth + _ = applyHostedInspectorDividerWidth( + inspectorWidth, + to: HostedInspectorDividerHit( + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView + ), + 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 + ) + ) +#endif + updateDividerCursor( + at: convert(event.locationInWindow, from: nil), + hostedInspectorHit: HostedInspectorDividerHit( + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView + ) + ) + } + + override func mouseUp(with event: NSEvent) { + let finalDragState = hostedInspectorDividerDrag + hostedInspectorDividerDrag = nil + updateDividerCursor(at: convert(event.locationInWindow, from: nil)) + scheduleHostedInspectorDividerReapply(reason: "dragEndAsync") +#if DEBUG + if let finalDragState { + let finalHit = HostedInspectorDividerHit( + containerView: finalDragState.containerView, + pageView: finalDragState.pageView, + inspectorView: finalDragState.inspectorView + ) + debugLogHostedInspectorFrames( + stage: "drag.end", + point: convert(event.locationInWindow, from: nil), + hit: finalHit + ) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reapplyHostedInspectorDividerIfNeeded(reason: "drag.end.async") + self.debugLogHostedInspectorFrames(stage: "drag.end.async", hit: finalHit) + self.debugLogHostedInspectorLayoutIfNeeded(reason: "dragEndAsync") + } + } +#endif + 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. @@ -3121,6 +3935,250 @@ 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? { + let inspectorCandidates = Self.visibleDescendants(in: self) + .filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) } + .sorted { lhs, rhs in + let lhsFrame = convert(lhs.bounds, from: lhs) + let rhsFrame = 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(startingAt: inspectorCandidate) else { + continue + } + let score = hostedInspectorDividerCandidateScore(candidate) + if score > bestScore { + bestScore = score + bestHit = candidate + } + } + + return bestHit + } + + 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) + let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY)) + let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY)) + return NSRect( + x: inspectorFrame.minX - Self.hostedInspectorDividerHitExpansion, + y: minY, + width: Self.hostedInspectorDividerHitExpansion * 2, + height: max(0, maxY - minY) + ) + } + + private func hostedInspectorDividerCandidate(startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? { + var current: NSView? = inspectorLeaf + var bestHit: HostedInspectorDividerHit? + + while let inspectorView = current, inspectorView !== self { + guard let containerView = inspectorView.superview else { break } + + let pageCandidates = containerView.subviews.filter { candidate in + guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return false } + guard candidate !== inspectorView else { return false } + guard candidate.frame.maxX <= inspectorView.frame.minX + 1 else { return false } + return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 + } + + if let pageView = pageCandidates.max(by: { + hostedInspectorPageCandidateScore($0, inspectorView: inspectorView) + < hostedInspectorPageCandidateScore($1, inspectorView: inspectorView) + }) { + bestHit = HostedInspectorDividerHit( + containerView: containerView, + pageView: pageView, + inspectorView: inspectorView + ) + } + + 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 + } + + private func scheduleHostedInspectorDividerReapply(reason: String) { + guard preferredHostedInspectorWidth != nil else { return } + DispatchQueue.main.async { [weak self] in + self?.reapplyHostedInspectorDividerIfNeeded(reason: reason) + } + } + + private func reapplyHostedInspectorDividerIfNeeded(reason: String) { + guard !isApplyingHostedInspectorLayout else { return } + guard let preferredWidth = preferredHostedInspectorWidth else { return } + guard let hit = hostedInspectorDividerCandidate() else { +#if DEBUG + if !hasLoggedMissingHostedInspectorCandidate { + hasLoggedMissingHostedInspectorCandidate = true + dlog( + "browser.panel.hostedInspector stage=\(reason).reapplyMissingCandidate " + + "host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth))" + ) + } +#endif + return + } +#if DEBUG + hasLoggedMissingHostedInspectorCandidate = false +#endif + _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason) + } + + @discardableResult + private func applyHostedInspectorDividerWidth( + _ preferredWidth: CGFloat, + to hit: HostedInspectorDividerHit, + reason: String + ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { + let containerBounds = hit.containerView.bounds + let maximumInspectorWidth = max(0, containerBounds.maxX - hit.pageView.frame.minX) + let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) + let dividerX = max(hit.pageView.frame.minX, containerBounds.maxX - clampedInspectorWidth) + + var pageFrame = hit.pageView.frame + pageFrame.size.width = max(0, dividerX - pageFrame.minX) + + var inspectorFrame = hit.inspectorView.frame + inspectorFrame.origin.x = dividerX + inspectorFrame.size.width = max(0, containerBounds.maxX - dividerX) + + let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5) + let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5) + guard pageChanged || inspectorChanged else { + return (pageFrame, inspectorFrame) + } + + isApplyingHostedInspectorLayout = true + CATransaction.begin() + CATransaction.setDisableActions(true) + hit.pageView.frame = pageFrame + hit.inspectorView.frame = inspectorFrame + CATransaction.commit() + isApplyingHostedInspectorLayout = false + + hit.pageView.needsLayout = true + hit.inspectorView.needsLayout = true + hit.containerView.needsLayout = true + needsLayout = true +#if DEBUG + dlog( + "browser.panel.hostedInspector stage=\(reason).reapply " + + "host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " + + "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 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 verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { + max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) + } } #if DEBUG @@ -3221,34 +4279,153 @@ struct WebViewRepresentable: NSViewRepresentable { host.onGeometryChanged = nil } - private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { - guard let host = nsView as? HostContainerView else { return } + 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 updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { + guard let host = nsView as? HostContainerView else { return false } 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 { @@ -3257,24 +4434,37 @@ struct WebViewRepresentable: NSViewRepresentable { panel.syncDeveloperToolsPreferenceFromInspector() } - 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. @@ -3285,6 +4475,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 @@ -3292,357 +4498,39 @@ 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 - ) { - let retryInterval: TimeInterval = 1.0 / 60.0 - // 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() + retryInterval) { - 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() + retryInterval, execute: work) + return portalHostAccepted } func updateNSView(_ nsView: NSView, context: Context) { let webView = panel.webView - context.coordinator.panel = panel - context.coordinator.webView = webView + 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 + + Self.clearPortalCallbacks(for: nsView) + let hostOwnsPortal = updateUsingWindowPortal(nsView, context: context, webView: webView) Self.applyWebViewFirstResponderPolicy( panel: panel, webView: webView, - isPanelFocused: isPanelFocused + isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal ) - 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 - } - - 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 - } - Self.applyFocus( panel: panel, webView: webView, nsView: nsView, - shouldFocusWebView: shouldFocusWebView, - isPanelFocused: isPanelFocused + shouldFocusWebView: shouldFocusWebView && isCurrentPaneOwner && hostOwnsPortal, + isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal ) } @@ -3654,20 +4542,53 @@ 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 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 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 } } @@ -3692,38 +4613,18 @@ struct WebViewRepresentable: NSViewRepresentable { } 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 @@ -3736,7 +4637,7 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "dismantle.resignFirstResponder", generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount, + retryCount: 0, details: attachContext(webView: webView, host: nsView) + " " + state.flags ) } @@ -3745,36 +4646,26 @@ 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 here; explicit detach + // still happens on real web view replacement and panel teardown. + BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight(for: webView, height: 0) + BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: 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 workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }), + let paneId = workspace.paneId(forPanelId: panel.id) else { + return nil } + return BrowserPaneDropContext( + workspaceId: panel.workspaceId, + panelId: panel.id, + paneId: paneId + ) } } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index c330f9ea..ed00bbd9 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -93,7 +93,7 @@ final class CmuxWebView: WKWebView { /// Temporarily permits focus acquisition for explicit pointer-driven interactions /// (mouse click into this webview) while keeping background autofocus blocked. - func withPointerFocusAllowance(_ body: () -> Void) { + func withPointerFocusAllowance<T>(_ body: () -> T) -> T { pointerFocusAllowanceDepth += 1 #if DEBUG dlog( @@ -110,7 +110,7 @@ final class CmuxWebView: WKWebView { ) #endif } - body() + return body() } override func performKeyEquivalent(with event: NSEvent) -> Bool { @@ -1113,6 +1113,11 @@ final class CmuxWebView: WKWebView { NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"), ] + static func shouldRejectInternalPaneDrag(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + || DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes) + } + override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) { let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) } if !filtered.isEmpty { @@ -1120,6 +1125,21 @@ final class CmuxWebView: WKWebView { } } + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] } + return super.draggingEntered(sender) + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] } + return super.draggingUpdated(sender) + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return false } + return super.performDragOperation(sender) + } + override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) lastContextMenuPoint = convert(event.locationInWindow, from: nil) @@ -1133,7 +1153,7 @@ final class CmuxWebView: WKWebView { 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 } @@ -1148,7 +1168,7 @@ 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 isDownloadImageMenuItem(item) { @@ -1188,7 +1208,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: "" ) 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 a0a719c4..09ec66b6 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -5,6 +5,7 @@ import Combine public enum PanelType: String, Codable, Sendable { case terminal case browser + case markdown } enum FocusFlashCurve: Equatable { 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 31345f70..8f30577f 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. @@ -164,9 +165,28 @@ final class TerminalPanel: Panel, ObservableObject { // The surface will be cleaned up by its deinit // 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() { @@ -195,6 +215,10 @@ final class TerminalPanel: Panel, ObservableObject { hostedView.triggerFlash() } + func triggerNotificationDismissFlash() { + hostedView.triggerFlash(style: .notificationDismiss) + } + func applyWindowBackgroundIfActive() { surface.applyWindowBackgroundIfActive() } diff --git a/Sources/PostHogAnalytics.swift b/Sources/PostHogAnalytics.swift index 031533aa..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,27 @@ 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 @@ -29,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 } @@ -49,31 +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.trackHourlyActive(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, @@ -81,53 +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 trackHourlyActive(reason: String) { - startIfNeeded() - guard didStart else { return } + @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 + return false } defaults.set(hour, forKey: lastActiveHourUTCKey) + let event = hourlyActiveEvent + PostHogSDK.shared.capture( - "cmux_hourly_active", + 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 } - func flush() { - guard didStart else { return } - PostHogSDK.shared.flush() + 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 { - let formatter = DateFormatter() - formatter.calendar = Calendar(identifier: .iso8601) - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM-dd'T'HH" - return formatter.string(from: date) + 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] { @@ -162,6 +247,15 @@ final class PostHogAnalytics { 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/SessionPersistence.swift b/Sources/SessionPersistence.swift index 289909df..53eb995e 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -235,6 +235,10 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { var forwardHistoryURLStrings: [String]? } +struct SessionMarkdownPanelSnapshot: Codable, Sendable { + var filePath: String +} + struct SessionPanelSnapshot: Codable, Sendable { var id: UUID var type: PanelType @@ -248,6 +252,7 @@ struct SessionPanelSnapshot: Codable, Sendable { var ttyName: String? var terminal: SessionTerminalPanelSnapshot? var browser: SessionBrowserPanelSnapshot? + var markdown: SessionMarkdownPanelSnapshot? } enum SessionSplitOrientation: String, Codable, Sendable { diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index f5a6825f..6a12a955 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -18,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 a local file." + 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.") } } @@ -183,7 +183,7 @@ enum SocketControlPasswordStore { throw NSError( domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, - userInfo: [NSLocalizedDescriptionKey: "Unable to resolve socket password file path."] + userInfo: [NSLocalizedDescriptionKey: String(localized: "socketControl.error.passwordFilePath", defaultValue: "Unable to resolve socket password file path.")] ) } let directory = fileURL.deletingLastPathComponent() diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0fa39b0a..c17d8b63 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.") } } } @@ -72,9 +72,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { var displayName: String { switch self { case .leftRail: - return "Left Rail" + return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail") case .solidFill: - return "Solid Fill" + return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill") } } } @@ -558,13 +558,23 @@ 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 - weak var window: NSWindow? + @Published private(set) var pendingBackgroundWorkspaceLoadIds: 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 } @@ -617,6 +627,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] = [] @@ -712,19 +728,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") } @@ -734,20 +765,43 @@ 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 } @@ -757,10 +811,12 @@ class TabManager: ObservableObject { initialTerminalCommand: String? = nil, initialTerminalEnvironment: [String: String] = [:], select: Bool = true, + eagerLoadTerminal: Bool = false, placementOverride: NewWorkspacePlacement? = nil ) -> Workspace { sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1]) - let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() + let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) + let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab() let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 @@ -779,6 +835,18 @@ class TabManager: ObservableObject { } 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( @@ -797,9 +865,187 @@ class TabManager: ObservableObject { return newWorkspace } + 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 pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) { + let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds) + guard pruned != pendingBackgroundWorkspaceLoadIds else { return } + pendingBackgroundWorkspaceLoadIds = pruned + } + // 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 } @@ -925,6 +1171,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) } @@ -1022,21 +1278,23 @@ 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) workspace.teardownRemoteConnection() 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 } } @@ -1045,6 +1303,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) @@ -1106,9 +1365,13 @@ class TabManager: ObservableObject { let count = plan.panelIds.count let titleLines = plan.titles.map { "• \($0)" }.joined(separator: "\n") - let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)" + 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: "Close other tabs?", + title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), message: message, acceptCmdD: false ) else { return } @@ -1148,8 +1411,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 @@ -1210,15 +1473,15 @@ class TabManager: ObservableObject { if let collapsed, !collapsed.isEmpty { return collapsed } - return "Untitled Tab" + 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 @@ -1259,8 +1522,8 @@ class TabManager: ObservableObject { 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)) " + @@ -1268,7 +1531,7 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: "Close tab?", + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), message: message, acceptCmdD: willCloseWindow ) else { @@ -1300,8 +1563,8 @@ class TabManager: ObservableObject { ) #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 { #if DEBUG @@ -1339,8 +1602,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 } } @@ -1567,19 +1830,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) { @@ -1864,9 +2145,24 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return } +#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)]) - _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: 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. @@ -1875,14 +2171,30 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return nil } +#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() - return newBrowserSplit( + 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. @@ -1983,11 +2295,20 @@ 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) -> 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 )?.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 @@ -2584,7 +2905,7 @@ class TabManager: ObservableObject { continue } terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh() + terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry") } } @@ -3550,6 +3871,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 @@ -3561,11 +3891,14 @@ 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") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index bc075ac8..34b9dce8 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13,6 +13,9 @@ class TerminalController { let acceptLoopAlive: Bool let socketPathMatches: Bool let socketPathExists: Bool + let socketProbePerformed: Bool + let socketConnectable: Bool? + let socketConnectErrno: Int32? var failureSignals: [String] { var signals: [String] = [] @@ -20,6 +23,9 @@ class TerminalController { if !acceptLoopAlive { signals.append("accept_loop_dead") } if !socketPathMatches { signals.append("socket_path_mismatch") } if !socketPathExists { signals.append("socket_missing") } + if socketProbePerformed && isRunning && acceptLoopAlive && socketPathMatches && socketPathExists && socketConnectable == false { + signals.append("socket_unreachable") + } return signals } @@ -34,6 +40,11 @@ class TerminalController { 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 @@ -41,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", @@ -127,6 +160,31 @@ class TerminalController { 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() } @@ -393,12 +451,14 @@ class TerminalController { errnoCode: Int32? = nil, extra: [String: Any] = [:] ) -> [String: Any] { + let snapshot = listenerStateSnapshot() var data: [String: Any] = [ "stage": stage, - "path": socketPath, - "isRunning": isRunning ? 1 : 0, - "acceptLoopAlive": acceptLoopAlive ? 1 : 0, - "serverSocket": Int(serverSocket) + "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) @@ -421,27 +481,201 @@ class TerminalController { 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( @@ -453,25 +687,24 @@ class TerminalController { } // Bind to path - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - strcpy(pathBuf, ptr) - } - } - - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - bind(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", @@ -483,10 +716,10 @@ class TerminalController { 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", @@ -495,14 +728,27 @@ class TerminalController { 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 + "mode": accessMode.rawValue, + "generation": generation, + "backlog": Self.socketListenBacklog ] ) @@ -520,40 +766,166 @@ class TerminalController { // Accept connections in background thread Thread.detachNewThread { [weak self] in - self?.acceptLoop() + self?.acceptLoop(listenerSocket: listenerSocket, generation: generation) } } nonisolated func socketListenerHealth(expectedSocketPath: String) -> SocketListenerHealth { - let running = isRunning - let loopAlive = acceptLoopAlive - let pathMatches = socketPath == expectedSocketPath + 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: running, - acceptLoopAlive: loopAlive, + isRunning: snapshot.isRunning, + acceptLoopAlive: snapshot.acceptLoopAlive, socketPathMatches: pathMatches, - socketPathExists: exists + socketPathExists: exists, + socketProbePerformed: shouldProbeConnection, + socketConnectable: connectability.isConnectable, + socketConnectErrno: connectability.errnoCode ) } - nonisolated func stop() { - isRunning = false - if serverSocket >= 0 { - close(serverSocket) - serverSocket = -1 + 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 { + 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 \(socketPath)") + print( + "TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(currentSocketPath)" + ) sentryBreadcrumb( "socket.listener.permissions.failed", category: "socket", @@ -657,27 +1029,77 @@ 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") + 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 { - if isRunning && exitReason == "stopped" { - exitReason = "unexpected_loop_exit" + 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) } - let shouldCaptureExit = exitReason != "stopped" - acceptLoopAlive = false - isRunning = false - if shouldCaptureExit { + + 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] + extra: [ + "reason": exitReason, + "generation": generation, + "errnoClass": lastAcceptErrnoClass, + "rearmRequested": rearmRequested ? 1 : 0 + ] ) sentryBreadcrumb("socket.listener.accept_loop.exited", category: "socket", data: data) sentryCaptureError( @@ -690,39 +1112,81 @@ class TerminalController { } 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 { - let errnoCode = errno - lastAcceptErrno = errnoCode - consecutiveFailures += 1 - print("TerminalController: Accept failed (\(consecutiveFailures) consecutive)") - if consecutiveFailures == 1 || consecutiveFailures % 10 == 0 { - sentryBreadcrumb( - "socket.listener.accept.failed", - category: "socket", - data: socketListenerEventData( - stage: "accept", - errnoCode: errnoCode, - extra: ["consecutiveFailures": consecutiveFailures] - ) + 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 } - if consecutiveFailures >= 50 { - print("TerminalController: Too many consecutive accept failures, exiting accept loop") - exitReason = "too_many_accept_failures" - break - } - 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 } @@ -741,6 +1205,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) } @@ -777,7 +1278,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 } @@ -891,7 +1392,7 @@ class TerminalController { return listNotifications() case "clear_notifications": - return clearNotifications() + return clearNotifications(args) case "set_app_focus": return setAppFocusOverride(args) @@ -982,6 +1483,9 @@ class TerminalController { #if DEBUG + case "send_workspace": + return sendInputToWorkspace(args) + case "set_shortcut": return setShortcut(args) @@ -1482,6 +1986,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)) @@ -1526,6 +2035,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": @@ -1616,6 +2127,7 @@ class TerminalController { "notification.clear", "app.focus_override.set", "app.simulate_active", + "markdown.open", "browser.open_split", "browser.navigate", "browser.back", @@ -1722,6 +2234,7 @@ class TerminalController { "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", @@ -2448,15 +2961,27 @@ class TerminalController { guard !key.isEmpty else { return } result[key] = pair.value } + let cwd: String? + if let workingDirectory { + cwd = workingDirectory + } else 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() v2MainSync { let ws = tabManager.addWorkspace( - workingDirectory: workingDirectory, + workingDirectory: cwd, initialTerminalCommand: initialCommand, initialTerminalEnvironment: initialEnv, - select: shouldFocus + select: shouldFocus, + eagerLoadTerminal: !shouldFocus ) newId = ws.id } @@ -3935,7 +4460,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 } } @@ -4008,9 +4533,22 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { - result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) - return + #if DEBUG + let sendStart = ProcessInfo.processInfo.systemUptime + #endif + let queued: Bool + if let surface = terminalPanel.surface.surface { + sendSocketText(text, surface: surface) + // 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(reason: "terminalController.v2SurfaceSendText") + queued = false + } else { + // Avoid blocking the main actor waiting for view/surface attachment. + terminalPanel.sendText(text) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + queued = true } for char in text { @@ -4061,7 +4599,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 @@ -4093,7 +4631,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, @@ -5167,41 +5705,70 @@ class TerminalController { _ webView: WKWebView, script: String, timeout: TimeInterval = 5.0, - preferAsync: Bool = false + preferAsync: Bool = false, + contentWorld: WKContentWorld ) -> V2JavaScriptResult { + let timeoutSeconds = max(0.01, timeout) + let resultLock = NSLock() + let completionSignal = DispatchSemaphore(value: 0) var done = false var resultValue: Any? var resultError: String? - if preferAsync, #available(macOS 11.0, *) { - webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in - switch result { - case .success(let value): - resultValue = value - case .failure(let error): - resultError = error.localizedDescription - } + let finish: (_ value: Any?, _ error: String?) -> Void = { value, error in + resultLock.lock() + if !done { done = true + resultValue = value + resultError = error + completionSignal.signal() + } + resultLock.unlock() + } + + let evaluator = { + if preferAsync, #available(macOS 11.0, *) { + webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in + switch result { + case .success(let value): + finish(value, nil) + case .failure(let error): + finish(nil, error.localizedDescription) + } + } + } else { + webView.evaluateJavaScript(script) { value, error in + if let error { + finish(nil, error.localizedDescription) + } else { + finish(value, nil) + } + } + } + } + + if Thread.isMainThread { + evaluator() + let deadline = Date().addingTimeInterval(timeoutSeconds) + while true { + resultLock.lock() + let isDone = done + resultLock.unlock() + if isDone { + break + } + if Date() >= deadline { + return .failure("Timed out waiting for JavaScript result") + } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) } } else { - webView.evaluateJavaScript(script) { value, error in - if let error { - resultError = error.localizedDescription - } else { - resultValue = value - } - done = true + DispatchQueue.main.async(execute: evaluator) + if completionSignal.wait(timeout: .now() + timeoutSeconds) == .timedOut { + return .failure("Timed out waiting for JavaScript result") } } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - - if !done { - return .failure("Timed out waiting for JavaScript result") - } if let resultError { return .failure(resultError) } @@ -5251,7 +5818,8 @@ class TerminalController { _ webView: WKWebView, surfaceId: UUID, script: String, - timeout: TimeInterval = 5.0 + timeout: TimeInterval = 5.0, + useEval: Bool = true ) -> V2JavaScriptResult { let scriptLiteral = v2JSONLiteral(script) let framePrelude: String @@ -5270,6 +5838,13 @@ class TerminalController { framePrelude = "const __cmuxDoc = document;" } + let executionBlock: String + if useEval { + executionBlock = "const __r = eval(\(scriptLiteral));" + } else { + executionBlock = "const __r = \(script);" + } + let asyncFunctionBody = """ \(framePrelude) @@ -5282,7 +5857,7 @@ class TerminalController { const __cmuxEvalInFrame = async function() { const document = __cmuxDoc; - const __r = eval(\(scriptLiteral)); + \(executionBlock) const __value = await __cmuxMaybeAwait(__r); return { __cmux_t: (typeof __value === 'undefined') ? 'undefined' : 'value', @@ -5293,16 +5868,40 @@ class TerminalController { return await __cmuxEvalInFrame(); """ - let rawResult: V2JavaScriptResult + var rawResult: V2JavaScriptResult if #available(macOS 11.0, *) { - rawResult = v2RunJavaScript(webView, script: asyncFunctionBody, timeout: timeout, preferAsync: true) + rawResult = v2RunJavaScript( + webView, + script: asyncFunctionBody, + timeout: timeout, + preferAsync: true, + contentWorld: .page + ) } else { let evaluateFallback = """ (async () => { \(asyncFunctionBody) })() """ - rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout) + rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout, contentWorld: .page) + } + + if !useEval, case .failure(let pageMessage) = rawResult, #available(macOS 11.0, *) { + let isolatedResult = v2RunJavaScript( + webView, + script: asyncFunctionBody, + timeout: timeout, + preferAsync: true, + contentWorld: .defaultClient + ) + switch isolatedResult { + case .success: + rawResult = isolatedResult + case .failure(let isolatedMessage): + if isolatedMessage != pageMessage { + rawResult = .failure("\(pageMessage) (isolated-world retry: \(isolatedMessage))") + } + } } switch rawResult { @@ -5403,44 +6002,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 { @@ -5448,13 +6140,36 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) + 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) let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let sourceSurfaceId else { @@ -5737,7 +6452,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): @@ -5995,7 +6710,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): @@ -6092,42 +6807,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) } } @@ -6472,13 +7265,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) } } @@ -7313,7 +8124,8 @@ class TerminalController { _ = v2RunJavaScript( browserPanel.webView, script: BrowserPanel.telemetryHookBootstrapScriptSource, - timeout: 5.0 + timeout: 5.0, + contentWorld: .page ) } @@ -7321,7 +8133,8 @@ class TerminalController { _ = v2RunJavaScript( browserPanel.webView, script: BrowserPanel.dialogTelemetryHookBootstrapScriptSource, - timeout: 5.0 + timeout: 5.0, + contentWorld: .page ) } @@ -7353,7 +8166,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): @@ -7948,7 +8761,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): @@ -7986,7 +8799,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): @@ -8691,6 +9504,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) } @@ -8944,7 +9764,7 @@ 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] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry @@ -9010,6 +9830,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) @@ -9075,74 +9896,90 @@ class TerminalController { 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 } + // 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) + } + } - // 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 - } + 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 { @@ -9179,20 +10016,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)) @@ -10007,7 +10843,7 @@ class TerminalController { var newTabId: UUID? let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { - let workspace = tabManager.addTab(select: focus) + let workspace = tabManager.addTab(select: focus, eagerLoadTerminal: !focus) newTabId = workspace.id } return "OK \(newTabId?.uuidString ?? "unknown")" @@ -10179,7 +11015,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 } @@ -10213,9 +11055,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" } @@ -10442,7 +11305,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 { @@ -10905,8 +11768,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) @@ -11161,6 +12024,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) @@ -12689,6 +13643,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, @@ -12784,7 +13739,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 bc3272b1..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 @@ -88,6 +634,11 @@ final class TerminalNotificationStore: ObservableObject { 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 { @@ -95,9 +646,11 @@ final class TerminalNotificationStore: ObservableObject { 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 @@ -118,6 +671,11 @@ final class TerminalNotificationStore: ObservableObject { 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() { @@ -130,6 +688,7 @@ final class TerminalNotificationStore: ObservableObject { self?.refreshDockBadge() } refreshDockBadge() + refreshAuthorizationStatus() } deinit { @@ -161,6 +720,98 @@ final class TerminalNotificationStore: ObservableObject { 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 { indexes.unreadCountByTabId[tabId] ?? 0 } @@ -187,17 +838,10 @@ final class TerminalNotificationStore: ObservableObject { let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId let isFocusedPanel = isActiveTab && isFocusedSurface let isAppFocused = AppFocusState.isAppFocused() - if isAppFocused && isFocusedPanel { - if !idsToClear.isEmpty { - notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) - } - return - } + let shouldSuppressExternalDelivery = isAppFocused && isFocusedPanel if WorkspaceAutoReorderSettings.isEnabled() { - AppDelegate.shared?.tabManager?.moveTabToTop(tabId) + AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId) } let notification = TerminalNotification( @@ -213,10 +857,12 @@ final class TerminalNotificationStore: ObservableObject { updated.insert(notification, at: 0) notifications = updated if !idsToClear.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) + } + if !shouldSuppressExternalDelivery { + notificationDeliveryHandler(self, notification) } - scheduleUserNotification(notification) } func markRead(id: UUID) { @@ -225,7 +871,7 @@ final class TerminalNotificationStore: ObservableObject { guard !updated[index].isRead else { return } updated[index].isRead = true notifications = updated - center.removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) } func markRead(forTabId tabId: UUID) { @@ -239,7 +885,7 @@ final class TerminalNotificationStore: ObservableObject { } if !idsToClear.isEmpty { notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) } } @@ -256,8 +902,8 @@ final class TerminalNotificationStore: ObservableObject { } if !idsToClear.isEmpty { notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } } @@ -286,8 +932,8 @@ final class TerminalNotificationStore: ObservableObject { } if !idsToClear.isEmpty { notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } } @@ -297,15 +943,15 @@ final class TerminalNotificationStore: ObservableObject { updated.removeAll { $0.id == id } guard updated.count != originalCount else { return } notifications = updated - center.removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) } func clearAll() { guard !notifications.isEmpty else { return } let ids = notifications.map { $0.id.uuidString } notifications.removeAll() - center.removeDeliveredNotifications(withIdentifiers: ids) - center.removePendingNotificationRequests(withIdentifiers: ids) + center.removeDeliveredNotificationsOffMain(withIdentifiers: ids) + center.removePendingNotificationRequestsOffMain(withIdentifiers: ids) } func clearNotifications(forTabId tabId: UUID, surfaceId: UUID?) { @@ -321,8 +967,8 @@ final class TerminalNotificationStore: ObservableObject { } guard !idsToClear.isEmpty else { return } notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } func clearNotifications(forTabId tabId: UUID) { @@ -338,12 +984,12 @@ final class TerminalNotificationStore: ObservableObject { } guard !idsToClear.isEmpty else { return } notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + 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() @@ -353,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, @@ -372,46 +1018,101 @@ 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 self.presentNotificationSettingsPrompt(attempt: 0) } @@ -432,19 +1133,59 @@ final class TerminalNotificationStore: ObservableObject { } let alert = notificationSettingsAlertFactory() - alert.messageText = "Enable Notifications for cmux" - alert.informativeText = "Notifications are disabled for cmux. Enable them in System Settings to see alerts." - alert.addButton(withTitle: "Open Settings") - alert.addButton(withTitle: "Not Now") + alert.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, - let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else { + guard response == .alertFirstButtonReturn else { return } - self?.notificationSettingsURLOpener(url) + 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 { @@ -492,6 +1233,18 @@ final class TerminalNotificationStore: ObservableObject { hasPromptedForSettings = false } + func configureNotificationDeliveryHandlerForTesting( + _ handler: @escaping (TerminalNotificationStore, TerminalNotification) -> Void + ) { + notificationDeliveryHandler = handler + } + + func resetNotificationDeliveryHandlerForTesting() { + notificationDeliveryHandler = { store, notification in + store.scheduleUserNotification(notification) + } + } + func promptToEnableNotificationsForTesting() { promptToEnableNotifications() } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 8e8dc306..80c0d2ba 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -54,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 { @@ -698,12 +705,13 @@ final class WindowTerminalPortal: NSObject { synchronizeAllHostedViews(excluding: nil) // During live resize, AppKit can deliver frame churn where host/container geometry - // settles a tick before the terminal's own scroll/surface hierarchy. Force a final - // in-place geometry + surface refresh for all visible entries in this window. + // 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 } - hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow() + if hostedView.reconcileGeometryNow() { + hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync") + } } } @@ -726,6 +734,7 @@ final class WindowTerminalPortal: NSObject { guard let window else { return false } guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window) else { return false } + let browserHost = preferredBrowserHost(in: container) if hostView.superview !== container || installedContainerView !== container || @@ -734,7 +743,11 @@ final class WindowTerminalPortal: NSObject { installConstraints.removeAll() hostView.removeFromSuperview() - container.addSubview(hostView, positioned: .above, relativeTo: reference) + if let browserHost { + container.addSubview(hostView, positioned: .below, relativeTo: browserHost) + } else { + container.addSubview(hostView, positioned: .above, relativeTo: reference) + } installConstraints = [ hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor), @@ -745,6 +758,10 @@ final class WindowTerminalPortal: NSObject { NSLayoutConstraint.activate(installConstraints) installedContainerView = container installedReferenceView = reference + } else if let browserHost { + if !Self.isView(browserHost, above: hostView, in: container) { + container.addSubview(hostView, positioned: .below, relativeTo: browserHost) + } } else if !Self.isView(hostView, above: reference, in: container) { container.addSubview(hostView, positioned: .above, relativeTo: reference) } @@ -837,6 +854,10 @@ final class WindowTerminalPortal: NSObject { return viewIndex > referenceIndex } + private func preferredBrowserHost(in container: NSView) -> WindowBrowserHostView? { + container.subviews.last(where: { $0 is WindowBrowserHostView }) as? WindowBrowserHostView + } + #if DEBUG private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? { var current: NSView? = anchorView @@ -1379,7 +1400,7 @@ final class WindowTerminalPortal: NSObject { hostedView.frame = targetFrame CATransaction.commit() hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow() + hostedView.refreshSurfaceNow(reason: "portal.frameChange") } if hasFiniteFrame { @@ -1418,7 +1439,7 @@ final class WindowTerminalPortal: NSObject { // 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() + hostedView.refreshSurfaceNow(reason: "portal.reveal") } if transientRecoveryReason == nil { @@ -1488,6 +1509,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 } @@ -1540,6 +1610,30 @@ final class WindowTerminalPortal: NSObject { enum TerminalWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] +#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 } @@ -1603,11 +1697,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], @@ -1674,5 +1803,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/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 7ff7d03b..1e4795ac 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -237,7 +237,7 @@ struct TitlebarControlsView: View { @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @State private var shortcutRefreshTick = 0 - @StateObject private var commandKeyMonitor = TitlebarCommandKeyMonitor() + @StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor() private let titlebarHintRightSafetyShift: CGFloat = 10 private let titlebarHintBaseXShift: CGFloat = -10 private let titlebarHintBaseYShift: CGFloat = 1 @@ -269,11 +269,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 @@ -283,7 +283,7 @@ struct TitlebarControlsView: View { .padding(.trailing, titlebarHintTrailingInset) .background( WindowAccessor { window in - commandKeyMonitor.setHostWindow(window) + modifierKeyMonitor.setHostWindow(window) } .frame(width: 0, height: 0) ) @@ -291,10 +291,10 @@ struct TitlebarControlsView: View { shortcutRefreshTick &+= 1 } .onAppear { - commandKeyMonitor.start() + modifierKeyMonitor.start() } .onDisappear { - commandKeyMonitor.stop() + modifierKeyMonitor.stop() } } @@ -320,8 +320,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 @@ -347,8 +347,8 @@ struct TitlebarControlsView: View { } .accessibilityIdentifier("titlebarControl.showNotifications") .background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }) - .accessibilityLabel("Notifications") - .help(KeyboardShortcutSettings.Action.showNotifications.tooltip("Show notifications")) + .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 @@ -359,8 +359,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) @@ -503,8 +503,8 @@ struct TitlebarControlsView: View { } @MainActor -private final class TitlebarCommandKeyMonitor: ObservableObject { - @Published private(set) var isCommandPressed = false +private final class TitlebarShortcutHintModifierMonitor: ObservableObject { + @Published private(set) var isModifierPressed = false private weak var hostWindow: NSWindow? private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? @@ -598,7 +598,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, eventWindowNumber: eventWindow?.windowNumber, @@ -607,7 +607,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: modifierFlags, hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, @@ -622,31 +622,31 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } private func queueHintShow() { - guard !isCommandPressed else { return } + guard !isModifierPressed else { return } guard pendingShowWorkItem == nil else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: NSEvent.modifierFlags, hostWindowNumber: self.hostWindow?.windowNumber, hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, eventWindowNumber: nil, keyWindowNumber: NSApp.keyWindow?.windowNumber ) else { return } - self.isCommandPressed = true + self.isModifierPressed = true } pendingShowWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem) } private func cancelPendingHintShow(resetVisible: Bool) { pendingShowWorkItem?.cancel() pendingShowWorkItem = nil if resetVisible { - isCommandPressed = false + isModifierPressed = false } } @@ -729,6 +729,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) @@ -901,11 +906,11 @@ private struct NotificationsPopoverView: View { 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") { + Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) { notificationStore.clearAll() } .buttonStyle(.bordered) @@ -921,9 +926,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) } diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index dd8a6697..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) } @@ -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 d8d23f7c..3aa5f16d 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -218,6 +218,12 @@ func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool { 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. @@ -295,6 +301,20 @@ func windowDragHandleShouldCaptureHit( 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 @@ -359,6 +379,13 @@ struct WindowDragHandleView: NSViewRepresentable { 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, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ca15be68..78e9b018 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1888,6 +1888,7 @@ final class Workspace: Identifiable, ObservableObject { let terminalSnapshot: SessionTerminalPanelSnapshot? let browserSnapshot: SessionBrowserPanelSnapshot? + let markdownSnapshot: SessionMarkdownPanelSnapshot? switch panel.panelType { case .terminal: guard let _ = panel as? TerminalPanel else { return nil } @@ -1901,6 +1902,7 @@ final class Workspace: Identifiable, ObservableObject { scrollback: resolvedScrollback ) browserSnapshot = nil + markdownSnapshot = nil case .browser: guard let browserPanel = panel as? BrowserPanel else { return nil } terminalSnapshot = nil @@ -1913,6 +1915,12 @@ final class Workspace: Identifiable, ObservableObject { 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( @@ -1927,7 +1935,8 @@ final class Workspace: Identifiable, ObservableObject { listeningPorts: listeningPorts, ttyName: ttyName, terminal: terminalSnapshot, - browser: browserSnapshot + browser: browserSnapshot, + markdown: markdownSnapshot ) } @@ -2077,6 +2086,19 @@ final class Workspace: Identifiable, ObservableObject { } 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 } } @@ -4815,21 +4837,34 @@ 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) + bonsplitAppearance( + from: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity + ) + } + + 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) } nonisolated static func resolvedChromeColors( @@ -4838,25 +4873,62 @@ final class Workspace: Identifiable, ObservableObject { .init(backgroundHex: backgroundColor.hexString()) } - private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { - let chromeColors = resolvedChromeColors(from: backgroundColor) - return BonsplitConfiguration.Appearance( + private static func bonsplitAppearance( + from backgroundColor: NSColor, + backgroundOpacity: Double + ) -> BonsplitConfiguration.Appearance { + BonsplitConfiguration.Appearance( splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, - chromeColors: chromeColors + chromeColors: .init( + backgroundHex: Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) + ) ) } - func applyGhosttyChrome(from config: GhosttyConfig) { - applyGhosttyChrome(backgroundColor: config.backgroundColor) + func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { + applyGhosttyChrome( + backgroundColor: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity, + reason: reason + ) } - func applyGhosttyChrome(backgroundColor: NSColor) { - let nextHex = backgroundColor.hexString() - if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextHex { + 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")" + ) + } + } + + func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { + applyGhosttyChrome( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundColor.alphaComponent, + reason: reason + ) } init( @@ -4882,7 +4954,12 @@ final class Workspace: Identifiable, ObservableObject { // Configure bonsplit with keepAllAlive to preserve terminal state // and keep split entry instantaneous. - let appearance = Self.bonsplitAppearance(from: GhosttyConfig.load()) + // 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, + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + ) let config = BonsplitConfiguration( allowSplits: true, allowCloseTabs: true, @@ -5079,6 +5156,30 @@ final class Workspace: Identifiable, ObservableObject { } } + 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)? { @@ -5094,12 +5195,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 } } @@ -5210,6 +5317,18 @@ final class Workspace: Identifiable, ObservableObject { return surfaceKind(for: panel) } + func requestBackgroundTerminalSurfaceStartIfNeeded() { + for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } + + 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 @@ -5838,11 +5957,21 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } + // 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 @@ -6097,15 +6226,166 @@ 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 @@ -6137,12 +6417,38 @@ final class Workspace: Identifiable, ObservableObject { dlog( "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + "selectedTab=\(String(describing: selected.id).prefix(5)) " + - "closed=\(closed ? 1 : 0)" + "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 @@ -6747,6 +7053,51 @@ final class Workspace: Identifiable, ObservableObject { if let targetPaneId { applyTabSelection(tabId: tabId, inPane: targetPaneId) } + + if let browserPanel = panels[panelId] as? BrowserPanel { + // Keep browser find focus behavior aligned with terminal find behavior. + // When switching back to a pane with an already-open find bar, reassert + // focus to that field instead of leaving first responder stale. + if browserPanel.searchState != nil { + browserPanel.startFind() + } else { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + } + } + } + + 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) { @@ -6875,7 +7226,7 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerFlash() + terminalPanel.triggerNotificationDismissFlash() } func triggerDebugFlash(panelId: UUID) { @@ -6895,6 +7246,19 @@ 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) @@ -7029,11 +7393,11 @@ final class Workspace: Identifiable, ObservableObject { needsFollowUpPass = true } - hostedView.reconcileGeometryNow() + let geometryChanged = hostedView.reconcileGeometryNow() // Re-check surface after reconcileGeometryNow() which can trigger AppKit // layout and view lifecycle changes that free surfaces (#432). - if terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh() + if geometryChanged, terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") } if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -7088,9 +7452,9 @@ final class Workspace: Identifiable, ObservableObject { 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 } - panel.hostedView.reconcileGeometryNow() - if panel.surface.surface != nil { - panel.surface.forceRefresh() + 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() @@ -7157,15 +7521,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 { @@ -7177,6 +7541,146 @@ 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 @@ -7185,11 +7689,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 { @@ -7383,7 +7887,11 @@ extension Workspace: BonsplitDelegate { // 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 refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() @@ -7391,12 +7899,15 @@ extension Workspace: BonsplitDelegate { 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 @@ -7441,6 +7952,13 @@ extension Workspace: BonsplitDelegate { if panels.isEmpty { if isDetaching { gitBranch = nil +#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() @@ -7453,6 +7971,13 @@ extension Workspace: BonsplitDelegate { refreshFocusedGitBranchState() 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 } @@ -7470,6 +7995,16 @@ extension Workspace: BonsplitDelegate { normalizePinnedTabs(in: pane) } refreshFocusedGitBranchState() +#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 + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() } @@ -7512,35 +8047,55 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { - _ = paneId - let liveTabIds: Set<TabID> = Set( - controller.allPaneIds.flatMap { controller.tabs(inPane: $0).map(\.id) } + 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)" ) - let staleMappings = surfaceIdToPanelId.filter { !liveTabIds.contains($0.key) } - for (staleTabId, stalePanelId) in staleMappings { - panels[stalePanelId]?.close() - panels.removeValue(forKey: stalePanelId) - surfaceIdToPanelId.removeValue(forKey: staleTabId) - panelDirectories.removeValue(forKey: stalePanelId) - panelTitles.removeValue(forKey: stalePanelId) - panelCustomTitles.removeValue(forKey: stalePanelId) - pinnedPanelIds.remove(stalePanelId) - manualUnreadPanelIds.remove(stalePanelId) - panelGitBranches.removeValue(forKey: stalePanelId) - panelPullRequests.removeValue(forKey: stalePanelId) - panelSubscriptions.removeValue(forKey: stalePanelId) - surfaceTTYNames.removeValue(forKey: stalePanelId) - surfaceListeningPorts.removeValue(forKey: stalePanelId) - restoredTerminalScrollbackByPanelId.removeValue(forKey: stalePanelId) - PortScanner.shared.unregisterPanel(workspaceId: id, panelId: stalePanelId) - } - if !staleMappings.isEmpty { +#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) + manualUnreadPanelIds.remove(panelId) + panelSubscriptions.removeValue(forKey: panelId) + surfaceTTYNames.removeValue(forKey: panelId) + surfaceListeningPorts.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + } + + let closedSet = Set(closedPanelIds) + surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() } refreshFocusedGitBranchState() 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 { diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 927b7910..edb26258 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -53,12 +53,52 @@ struct WorkspaceContentView: View { }() BonsplitView(controller: workspace.bonsplitController) { tab, paneId in - panelView( - tab: tab, - paneId: paneId, - isSplit: isSplit, - appearance: appearance - ) + // Content for each tab in bonsplit + let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) + 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 = 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, + portalPriority: workspacePortalPriority, + isSplit: isSplit, + appearance: appearance, + hasUnreadNotification: hasUnreadNotification, + onFocus: { + // Keep bonsplit focus in sync with the AppKit first responder for the + // active workspace. This prevents divergence between the blue focused-tab + // 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, trigger: .terminalFirstResponder) + }, + onRequestPanelFocus: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.focusPanel(panel.id) + }, + onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } + ) + .onTapGesture { + workspace.bonsplitController.focusPane(paneId) + } + } else { + // Fallback for tabs without panels (shouldn't happen normally) + EmptyPanelView(workspace: workspace, paneId: paneId) + } } emptyPane: { paneId in // Empty pane content EmptyPanelView(workspace: workspace, paneId: paneId) @@ -104,55 +144,6 @@ struct WorkspaceContentView: View { } } - @ViewBuilder - private func panelView( - tab: Bonsplit.Tab, - paneId: PaneID, - isSplit: Bool, - appearance: PanelAppearance - ) -> some View { - let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) - 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 = Self.panelVisibleInUI( - isWorkspaceVisible: isWorkspaceVisible, - isSelectedInPane: isSelectedInPane, - isFocused: isFocused - ) - let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), - isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) - ) - PanelContentView( - panel: panel, - isFocused: isFocused, - isSelectedInPane: isSelectedInPane, - isVisibleInUI: isVisibleInUI, - portalPriority: workspacePortalPriority, - isSplit: isSplit, - appearance: appearance, - hasUnreadNotification: hasUnreadNotification, - onFocus: { - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id) - }, - onRequestPanelFocus: { - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id) - }, - onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } - ) - .onTapGesture { - workspace.bonsplitController.focusPane(paneId) - } - } else { - EmptyPanelView(workspace: workspace, paneId: paneId) - } - } - private func syncBonsplitNotificationBadges() { let unreadFromNotifications: Set<UUID> = Set( notificationStore.notifications @@ -187,7 +178,8 @@ struct WorkspaceContentView: View { reason: String = "unspecified", backgroundOverride: NSColor? = nil, loadConfig: () -> GhosttyConfig = { GhosttyConfig.load() }, - defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor } + defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }, + defaultBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity } ) -> GhosttyConfig { var next = loadConfig() let loadedBackgroundHex = next.backgroundColor.hexString() @@ -204,9 +196,12 @@ struct WorkspaceContentView: View { } 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()) theme=\(next.theme ?? "nil")" + "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 @@ -228,7 +223,8 @@ struct WorkspaceContentView: View { let sourceLabel = backgroundSource ?? "nil" let payloadLabel = notificationPayloadHex ?? "nil" let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString() - let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear" + 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")" ) @@ -253,8 +249,7 @@ struct WorkspaceContentView: View { ) let chromeReason = "refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)" - _ = chromeReason - workspace.applyGhosttyChrome(from: next) + workspace.applyGhosttyChrome(from: next, reason: chromeReason) if let terminalPanel = workspace.focusedTerminalPanel { terminalPanel.applyWindowBackgroundIfActive() logTheme( @@ -411,7 +406,7 @@ struct EmptyPanelView: View { } } .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 80bde744..99ea9f8f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2,6 +2,7 @@ import AppKit import SwiftUI import Darwin import Bonsplit +import UniformTypeIdentifiers @main struct cmuxApp: App { @@ -13,6 +14,8 @@ 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() @@ -43,6 +46,9 @@ struct cmuxApp: App { 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()) @@ -57,7 +63,15 @@ struct cmuxApp: App { defaults.set(legacy ? SocketControlMode.cmuxOnly.rawValue : SocketControlMode.off.rawValue, forKey: SocketControlSettings.appStorageKey) } - SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(defaults: defaults) + // 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 @@ -212,25 +226,25 @@ struct cmuxApp: App { .windowStyle(.hiddenTitleBar) .commands { CommandGroup(replacing: .appSettings) { - Button("Settings…") { + 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") { + 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) @@ -256,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) {} @@ -283,21 +288,21 @@ struct cmuxApp: App { Divider() } - splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) { + splitCommandButton(title: String(localized: "menu.notifications.show", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) { showNotificationsPopover() } - splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) { + 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) @@ -348,6 +353,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() @@ -359,6 +368,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) } @@ -367,11 +385,11 @@ struct cmuxApp: App { // New tab commands CommandGroup(replacing: .newItem) { - splitCommandButton(title: "New Window", shortcut: newWindowMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), shortcut: newWindowMenuShortcut) { appDelegate.openNewMainWindow(nil) } - splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) { + 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 @@ -386,13 +404,13 @@ struct cmuxApp: App { } } - splitCommandButton(title: "Open Folder…", shortcut: openFolderMenuShortcut) { + 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 = "Open Folder" - panel.prompt = "Open" + 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( @@ -410,13 +428,13 @@ struct cmuxApp: App { // Close tab/workspace CommandGroup(after: .newItem) { - Button("Go to Workspace or Tab…") { + 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("Command Palette…") { + Button(String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…")) { let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) } @@ -427,12 +445,12 @@ struct cmuxApp: App { // 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("Close Other Tabs in Pane") { + Button(String(localized: "menu.file.closeOtherTabs", defaultValue: "Close Other Tabs in Pane")) { closeOtherTabsInFocusedPane() } .keyboardShortcut("t", modifiers: [.command, .option]) @@ -440,11 +458,11 @@ struct cmuxApp: App { // Cmd+Shift+W closes the current workspace (with confirmation if needed). If this // is the last workspace, it closes the window. - splitCommandButton(title: "Close Workspace", shortcut: closeWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace"), shortcut: closeWorkspaceMenuShortcut) { closeTabOrWindow() } - Button("Reopen Closed Browser Panel") { + Button(String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel")) { _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() } .keyboardShortcut("t", modifiers: [.command, .shift]) @@ -452,25 +470,28 @@ struct cmuxApp: App { // Find CommandGroup(after: .textEditing) { - Menu("Find") { - Button("Find…") { + 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") { + Button(String(localized: "menu.find.findNext", defaultValue: "Find Next")) { activeTabManager.findNext() } .keyboardShortcut("g", modifiers: .command) - Button("Find Previous") { + Button(String(localized: "menu.find.findPrevious", defaultValue: "Find Previous")) { activeTabManager.findPrevious() } .keyboardShortcut("g", modifiers: [.command, .shift]) Divider() - Button("Hide Find Bar") { + Button(String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar")) { activeTabManager.hideFind() } .keyboardShortcut("f", modifiers: [.command, .shift]) @@ -478,7 +499,7 @@ struct cmuxApp: App { Divider() - Button("Use Selection for Find") { + Button(String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find")) { activeTabManager.searchSelection() } .keyboardShortcut("e", modifiers: .command) @@ -488,7 +509,7 @@ struct cmuxApp: App { // Tab navigation CommandGroup(after: .toolbar) { - splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.toggleSidebar", defaultValue: "Toggle Sidebar"), shortcut: toggleSidebarMenuShortcut) { if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true { sidebarState.toggle() } @@ -496,89 +517,89 @@ struct cmuxApp: App { Divider() - splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: nextSurfaceMenuShortcut) { activeTabManager.selectNextSurface() } - splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.previousSurface", defaultValue: "Previous Surface"), shortcut: prevSurfaceMenuShortcut) { activeTabManager.selectPreviousSurface() } - Button("Back") { + Button(String(localized: "menu.view.back", defaultValue: "Back")) { activeTabManager.focusedBrowserPanel?.goBack() } .keyboardShortcut("[", modifiers: .command) - Button("Forward") { + Button(String(localized: "menu.view.forward", defaultValue: "Forward")) { activeTabManager.focusedBrowserPanel?.goForward() } .keyboardShortcut("]", modifiers: .command) - Button("Reload Page") { + Button(String(localized: "menu.view.reloadPage", defaultValue: "Reload Page")) { activeTabManager.focusedBrowserPanel?.reload() } .keyboardShortcut("r", modifiers: .command) - splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { + 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) { + splitCommandButton(title: String(localized: "menu.view.showJSConsole", defaultValue: "Show JavaScript Console"), shortcut: showBrowserJavaScriptConsoleMenuShortcut) { let manager = activeTabManager if !manager.showJavaScriptConsoleFocusedBrowser() { NSSound.beep() } } - Button("Zoom In") { + Button(String(localized: "menu.view.zoomIn", defaultValue: "Zoom In")) { _ = activeTabManager.zoomInFocusedBrowser() } .keyboardShortcut("=", modifiers: .command) - Button("Zoom Out") { + Button(String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out")) { _ = activeTabManager.zoomOutFocusedBrowser() } .keyboardShortcut("-", modifiers: .command) - Button("Actual Size") { + 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() } - splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) { activeTabManager.selectNextTab() } - splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.previousWorkspace", defaultValue: "Previous Workspace"), shortcut: prevWorkspaceMenuShortcut) { activeTabManager.selectPreviousTab() } - splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) { + 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) } @@ -586,7 +607,7 @@ struct cmuxApp: App { // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in - Button("Workspace \(number)") { + 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) @@ -597,11 +618,11 @@ struct cmuxApp: App { Divider() - splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: jumpToUnreadMenuShortcut) { AppDelegate.shared?.jumpToLatestUnread() } - splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.showNotifications", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) { showNotificationsPopover() } } @@ -1306,6 +1327,7 @@ private enum DebugWindowConfigSnapshot { 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))) @@ -1313,10 +1335,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))) @@ -1695,7 +1718,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()) @@ -1720,7 +1743,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 { @@ -1759,7 +1782,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { 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)") @@ -1769,12 +1792,39 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { 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) + } +} + private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate { static let shared = SidebarDebugWindowController() @@ -1836,10 +1886,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) @@ -1850,31 +1900,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() } } @@ -1915,6 +1965,8 @@ 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 @@ -2138,6 +2190,7 @@ private struct SidebarDebugView: View { 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))) @@ -2373,7 +2426,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 { @@ -2419,7 +2472,7 @@ private struct BackgroundDebugView: View { bgGlassTintHex = "#000000" bgGlassTintOpacity = 0.03 bgGlassMaterial = "hudWindow" - bgGlassEnabled = true + bgGlassEnabled = false updateWindowGlassTint() } @@ -2565,13 +2618,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") } } } @@ -2601,6 +2654,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 @@ -2660,7 +2834,9 @@ struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 + @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 @@ -2675,11 +2851,19 @@ struct SettingsView: View { @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 @@ -2689,10 +2873,13 @@ struct SettingsView: View { @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? @@ -2705,7 +2892,13 @@ 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() @@ -2767,11 +2960,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.") } } @@ -2779,16 +2972,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 } @@ -2796,10 +3184,10 @@ struct SettingsView: View { do { try SocketControlPasswordStore.savePassword(trimmed) socketPasswordDraft = "" - socketPasswordStatusMessage = "Password saved." + 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 } } @@ -2808,51 +3196,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() @@ -2862,8 +3285,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() @@ -2873,10 +3296,111 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Send anonymous telemetry", + "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.") + ) { + 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) + } + } + } + } + + 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 - ? "Change takes effect on next launch." - : "Share anonymized crash and usage data to help improve cmux." + ? 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() @@ -2886,10 +3410,10 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Warn Before Quit", + 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() @@ -2899,10 +3423,10 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Rename Selects Existing Name", + String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), subtitle: commandPaletteRenameSelectAllOnFocus - ? "Command Palette rename starts with all text selected." - : "Command Palette rename keeps the caret at the end." + ? 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.") ) { Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) .labelsHidden() @@ -2911,25 +3435,23 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow( - "Sidebar Branch Layout", + SettingsPickerRow( + String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"), subtitle: sidebarBranchVerticalLayout - ? "Vertical: each branch appears on its own line." - : "Inline: all branches share one line." + ? 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 ) { - Picker("", selection: $sidebarBranchVerticalLayout) { - Text("Vertical").tag(true) - Text("Inline").tag(false) - } - .labelsHidden() - .pickerStyle(.menu) + Text(String(localized: "settings.app.sidebarBranchLayout.vertical", defaultValue: "Vertical")).tag(true) + Text(String(localized: "settings.app.sidebarBranchLayout.inline", defaultValue: "Inline")).tag(false) } SettingsCardDivider() SettingsCardRow( - "Show Branch + Directory in Sidebar", - subtitle: "Display the built-in git branch and working-directory row." + 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() @@ -2939,8 +3461,8 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Show Pull Requests in Sidebar", - subtitle: "Display review items (PR/MR/etc.) with status, number, and clickable link." + 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() @@ -2950,10 +3472,10 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Open Sidebar PR Links in cmux Browser", + String(localized: "settings.app.openSidebarPRLinks", defaultValue: "Open Sidebar PR Links in cmux Browser"), subtitle: openSidebarPullRequestLinksInCmuxBrowser - ? "Clicks open inside cmux browser." - : "Clicks open in your default browser." + ? 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() @@ -2963,8 +3485,8 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Show Listening Ports in Sidebar", - subtitle: "Display detected listening ports for the active workspace." + 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() @@ -2974,8 +3496,8 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Show Latest Log in Sidebar", - subtitle: "Display the latest imperative log/status message." + 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() @@ -2985,8 +3507,8 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Show Progress in Sidebar", - subtitle: "Display the built-in progress bar from set_progress." + 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() @@ -2996,8 +3518,8 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Show Custom Metadata in Sidebar", - subtitle: "Display custom metadata from report_meta/set_status and report_meta_block." + 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() @@ -3005,24 +3527,21 @@ struct SettingsView: View { } } - SettingsSectionHeader(title: "Workspace Colors") + SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) SettingsCard { - SettingsCardRow( - "Workspace Color Indicator", - controlWidth: pickerColumnWidth + SettingsPickerRow( + String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"), + controlWidth: pickerColumnWidth, + selection: sidebarIndicatorStyleSelection ) { - Picker("", selection: sidebarIndicatorStyleSelection) { - ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in - Text(style.displayName).tag(style.rawValue) - } + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) } - .labelsHidden() - .pickerStyle(.menu) } SettingsCardDivider() - SettingsCardNote("Customize the workspace color palette used by Sidebar > Workspace Color. \"Choose Custom Color...\" entries are persisted below.") + 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 { @@ -3030,7 +3549,7 @@ struct SettingsView: View { } SettingsCardRow( entry.name, - subtitle: "Base: \(baseTabColorHex(for: entry.name))" + subtitle: String(localized: "settings.workspaceColors.base", defaultValue: "Base: \(baseTabColorHex(for: entry.name))") ) { HStack(spacing: 8) { ColorPicker( @@ -3052,10 +3571,10 @@ struct SettingsView: View { SettingsCardDivider() if workspaceTabCustomColors.isEmpty { - SettingsCardNote("Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu.") + 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("Custom Colors") + Text(String(localized: "settings.workspaceColors.customColors", defaultValue: "Custom Colors")) .font(.system(size: 13, weight: .semibold)) ForEach(workspaceTabCustomColors, id: \.self) { hex in @@ -3070,7 +3589,7 @@ struct SettingsView: View { Spacer(minLength: 8) - Button("Remove") { + Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { removeWorkspaceCustomColor(hex) } .buttonStyle(.bordered) @@ -3085,10 +3604,10 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Reset Palette", - subtitle: "Restore built-in defaults and clear all custom colors." + 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("Reset") { + Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { resetWorkspaceTabColors() } .buttonStyle(.bordered) @@ -3096,46 +3615,43 @@ struct SettingsView: View { } } - SettingsSectionHeader(title: "Automation") + SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation")) SettingsCard { - SettingsCardRow( - "Socket Control Mode", + SettingsPickerRow( + String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), subtitle: selectedSocketControlMode.description, - controlWidth: pickerColumnWidth + controlWidth: pickerColumnWidth, + selection: socketModeSelection, + accessibilityId: "AutomationSocketModePicker" ) { - Picker("", selection: socketModeSelection) { - ForEach(SocketControlMode.uiCases) { mode in - Text(mode.displayName).tag(mode.rawValue) - } + ForEach(SocketControlMode.uiCases) { mode in + Text(mode.displayName).tag(mode.rawValue) } - .labelsHidden() - .pickerStyle(.menu) - .accessibilityIdentifier("AutomationSocketModePicker") } SettingsCardDivider() - SettingsCardNote("Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.") + 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 Application Support." - : "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) @@ -3153,21 +3669,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() @@ -3177,11 +3693,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) @@ -3189,7 +3705,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) @@ -3197,28 +3713,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) @@ -3226,27 +3739,24 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow( - "Browser Theme", + SettingsPickerRow( + String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), subtitle: selectedBrowserThemeMode == .system - ? "System follows app and macOS appearance." - : "\(selectedBrowserThemeMode.displayName) forces that color scheme for compatible pages.", - controlWidth: pickerColumnWidth + ? 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 ) { - Picker("", selection: browserThemeModeSelection) { - ForEach(BrowserThemeMode.allCases) { mode in - Text(mode.displayName).tag(mode.rawValue) - } + ForEach(BrowserThemeMode.allCases) { mode in + Text(mode.displayName).tag(mode.rawValue) } - .labelsHidden() - .pickerStyle(.menu) } SettingsCardDivider() SettingsCardRow( - "Open Terminal Links in cmux Browser", - subtitle: "When off, links clicked in terminal output open in your default browser." + 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() @@ -3256,8 +3766,8 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Intercept open http(s) in Terminal", - subtitle: "When off, `open https://...` and `open http://...` always use your default browser." + 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() @@ -3269,8 +3779,8 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 6) { SettingsCardRow( - "Hosts to Open in Embedded Browser", - subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux." + 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() } @@ -3289,15 +3799,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) @@ -3317,14 +3852,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) @@ -3334,13 +3869,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) @@ -3356,8 +3891,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) @@ -3366,8 +3901,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) @@ -3380,16 +3930,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) @@ -3455,7 +4006,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) @@ -3478,10 +4029,18 @@ struct SettingsView: View { .toggleStyle(.switch) .onAppear { BrowserHistoryStore.shared.loadIfNeeded() + notificationStore.refreshAuthorizationStatus() browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist 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. @@ -3495,37 +4054,89 @@ struct SettingsView: View { .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 @@ -3535,11 +4146,21 @@ struct SettingsView: View { 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 @@ -3547,6 +4168,7 @@ struct SettingsView: View { sidebarShowBranchDirectory = true sidebarShowPullRequest = true openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold sidebarShowPorts = true sidebarShowLog = true sidebarShowProgress = true @@ -3560,6 +4182,7 @@ struct SettingsView: View { WorkspaceTabColorSettings.reset() reloadWorkspaceTabColorSettings() shortcutResetToken = UUID() + DispatchQueue.main.async { isResettingSettings = false } } private func defaultTabColorBinding(for name: String) -> Binding<Color> { @@ -3715,6 +4338,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() @@ -3740,6 +4431,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/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh deleted file mode 100755 index 986b22a8..00000000 --- a/ci_scripts/ci_post_clone.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -set -euo pipefail - -echo "=== ci_post_clone.sh ===" - -# Initialize submodules (needed for vendor/bonsplit SPM package) -echo "Initializing submodules..." -git submodule update --init --recursive - -# Get ghostty submodule SHA -GHOSTTY_SHA=$(git -C "$CI_PRIMARY_REPOSITORY_PATH/ghostty" rev-parse HEAD) -echo "Ghostty SHA: $GHOSTTY_SHA" - -# Download pre-built xcframework from manaflow-ai/ghostty releases -TAG="xcframework-$GHOSTTY_SHA" -URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" - -echo "Downloading xcframework from $URL" - -MAX_RETRIES=30 -RETRY_DELAY=20 - -for i in $(seq 1 $MAX_RETRIES); do - if curl -fSL -o "$CI_PRIMARY_REPOSITORY_PATH/GhosttyKit.xcframework.tar.gz" "$URL"; then - echo "Download succeeded on attempt $i" - break - fi - if [ "$i" -eq "$MAX_RETRIES" ]; then - echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 - exit 1 - fi - echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." - sleep $RETRY_DELAY -done - -# Extract xcframework to project root -echo "Extracting xcframework..." -cd "$CI_PRIMARY_REPOSITORY_PATH" -tar xzf GhosttyKit.xcframework.tar.gz -rm GhosttyKit.xcframework.tar.gz -test -d GhosttyKit.xcframework -echo "GhosttyKit.xcframework extracted successfully" - -# Download Metal toolchain (required for shader compilation) -echo "Downloading Metal toolchain..." -xcodebuild -downloadComponent MetalToolchain - -echo "=== ci_post_clone.sh done ===" diff --git a/ci_scripts/ci_pre_xcodebuild.sh b/ci_scripts/ci_pre_xcodebuild.sh deleted file mode 100755 index d4def0b5..00000000 --- a/ci_scripts/ci_pre_xcodebuild.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -set -euo pipefail - -ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$PWD}" -cd "$ROOT" - -echo "ci_pre_xcodebuild: repository root is $ROOT" - -if [ -f "vendor/bonsplit/Package.swift" ]; then - echo "ci_pre_xcodebuild: vendor/bonsplit already present" - exit 0 -fi - -if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "ci_pre_xcodebuild: attempting submodule init for vendor/bonsplit" - git submodule sync --recursive || true - git submodule update --init --recursive vendor/bonsplit || true -fi - -if [ ! -f "vendor/bonsplit/Package.swift" ]; then - echo "ci_pre_xcodebuild: submodule not present, cloning fallback" - rm -rf vendor/bonsplit - mkdir -p vendor - git clone --depth 1 https://github.com/manaflow-ai/bonsplit.git vendor/bonsplit - - if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - expected_sha="$(git ls-tree HEAD vendor/bonsplit | awk '{print $3}')" - if [ -n "${expected_sha:-}" ]; then - ( - cd vendor/bonsplit - git fetch --depth 1 origin "$expected_sha" || true - git checkout "$expected_sha" || true - ) - fi - fi -fi - -if [ ! -f "vendor/bonsplit/Package.swift" ]; then - echo "ci_pre_xcodebuild: missing vendor/bonsplit/Package.swift after recovery" >&2 - exit 1 -fi - -echo "ci_pre_xcodebuild: vendor/bonsplit is ready" diff --git a/cmux.entitlements b/cmux.entitlements index ec456f35..09e191a5 100644 --- a/cmux.entitlements +++ b/cmux.entitlements @@ -8,6 +8,8 @@ <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> diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index eaf8fb61..63ff111f 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -8,6 +8,39 @@ import XCTest @MainActor final class AppDelegateShortcutRoutingTests: XCTestCase { + private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + private var actionsWithPersistedShortcut: Set<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?.dismissNotificationsPopoverIfShown() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + for action in KeyboardShortcutSettings.Action.allCases { + if actionsWithPersistedShortcut.contains(action), + let savedShortcut = savedShortcutsByAction[action] { + KeyboardShortcutSettings.setShortcut(savedShortcut, for: action) + } else { + KeyboardShortcutSettings.resetShortcut(for: action) + } + } + super.tearDown() + } + func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -311,6 +344,910 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") } + func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut(action: .showNotifications) { + // Dvorak: physical ANSI "I" key can produce the character "c". + // This should behave like Cmd+C (copy), not match the Cmd+I app shortcut. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 34 // kVK_ANSI_I + ) else { + XCTFail("Failed to construct Dvorak Cmd+C event on physical ANSI I key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdPhysicalPWithDvorakCharactersDoesNotTriggerCommandPaletteSwitcher() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let switcherExpectation = expectation(description: "Cmd+L should not request command palette switcher") + switcherExpectation.isInverted = true + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + // Dvorak: physical ANSI "P" key can produce "l". + // This should behave as Cmd+L, not as physical Cmd+P. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "l", + charactersIgnoringModifiers: "l", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Dvorak Cmd+L event on physical ANSI P key") + return + } + +#if DEBUG + _ = appDelegate.debugHandleCustomShortcut(event: event) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPWithCapsLockStillTriggersCommandPaletteSwitcher() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let switcherExpectation = expectation(description: "Cmd+P with Caps Lock should request command palette switcher") + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .capsLock], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "p", + charactersIgnoringModifiers: "p", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P + Caps Lock event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPFallsBackToANSIKeyCodeWhenCharactersAndLayoutTranslationAreUnavailable() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.shortcutLayoutCharacterProvider = { _, _ in nil } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + let switcherExpectation = expectation(description: "Cmd+P with unavailable characters should request command palette switcher") + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P event with unavailable characters") + return + } + + XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event)) + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPDoesNotFallbackToANSIKeyCodeWhenLayoutTranslationProvidesDifferentLetter() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.shortcutLayoutCharacterProvider = { _, _ in "b" } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + let switcherExpectation = expectation(description: "Non-P layout translation should not request command palette switcher") + switcherExpectation.isInverted = true + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P event with unavailable characters") + return + } + + _ = appDelegate.handleBrowserSurfaceKeyEquivalent(event) + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPFallsBackToCommandAwareLayoutTranslationWhenCharactersAreUnavailable() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.shortcutLayoutCharacterProvider = { keyCode, modifierFlags in + guard keyCode == 35 else { return nil } // kVK_ANSI_P + return modifierFlags.contains(.command) ? "p" : "r" + } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + let switcherExpectation = expectation(description: "Command-aware layout translation should request command palette switcher") + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P event with unavailable characters") + return + } + + XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event)) + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdShiftPhysicalPWithDvorakCharactersDoesNotTriggerCommandPalette() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let paletteExpectation = expectation(description: "Cmd+Shift+L should not request command palette") + paletteExpectation.isInverted = true + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteRequested, + object: nil, + queue: nil + ) { _ in + paletteExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + // Dvorak: physical ANSI "P" key can produce "l". + // This should behave as Cmd+Shift+L, not as physical Cmd+Shift+P. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "l", + charactersIgnoringModifiers: "l", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Dvorak Cmd+Shift+L event on physical ANSI P key") + return + } + +#if DEBUG + _ = appDelegate.debugHandleCustomShortcut(event: event) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [paletteExpectation], timeout: 0.15) + } + + func testCmdOptionPhysicalTWithDvorakCharactersDoesNotTriggerCloseOtherTabsShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + // Dvorak: physical ANSI "T" key can produce "y". + // This should not match the Cmd+Option+T app shortcut. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .option], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "y", + charactersIgnoringModifiers: "y", + isARepeat: false, + keyCode: 17 // kVK_ANSI_T + ) else { + XCTFail("Failed to construct Dvorak Cmd+Option+Y event on physical ANSI T key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window and workspace") + return + } + + let panelCountBefore = workspace.panels.count + + // Dvorak: physical ANSI "W" key can produce ",". + // This should not match the Cmd+W close-panel shortcut. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: ",", + charactersIgnoringModifiers: ",", + isARepeat: false, + keyCode: 13 // kVK_ANSI_W + ) else { + XCTFail("Failed to construct Dvorak Cmd+, event on physical ANSI W key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertEqual(workspace.panels.count, panelCountBefore) + } + + func testCmdIStillTriggersShowNotificationsShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut(action: .showNotifications) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "i", + charactersIgnoringModifiers: "i", + isARepeat: false, + keyCode: 34 // kVK_ANSI_I + ) else { + XCTFail("Failed to construct Cmd+I event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdUnshiftedSymbolDoesNotMatchDigitShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "8", command: true, shift: false, option: false, control: false) + ) { + // Some non-US layouts can produce "*" without Shift. + // This must not be coerced into "8" for a Cmd+8 shortcut match. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct Cmd+* event") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdDigitShortcutFallsBackByKeyCodeOnSymbolFirstLayouts() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) + ) { + // Symbol-first layouts (for example AZERTY) can report "&" for the ANSI 1 key. + // Cmd+1 shortcuts should still match via keyCode fallback in this case. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "&", + charactersIgnoringModifiers: "&", + isARepeat: false, + keyCode: 18 // kVK_ANSI_1 + ) else { + XCTFail("Failed to construct Cmd+& event on ANSI 1 key") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftNonDigitKeySymbolDoesNotMatchShiftedDigitShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false) + ) { + // Avoid unrelated default Cmd+Shift+] handling for this assertion. + withTemporaryShortcut( + action: .nextSurface, + shortcut: StoredShortcut(key: "x", command: true, shift: true, option: false, control: false) + ) { + // On some non-US layouts, Shift+RightBracket can produce "*". + // This must not be interpreted as Shift+8. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct Cmd+Shift+* event from non-digit key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + } + + func testCmdShiftDigitShortcutMatchesShiftedDigitKey() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false) + ) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 28 // kVK_ANSI_8 + ) else { + XCTFail("Failed to construct Cmd+Shift+8 event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftQuestionMarkMatchesSlashShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .triggerFlash, + shortcut: StoredShortcut(key: "/", command: true, shift: true, option: false, control: false) + ) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "?", + charactersIgnoringModifiers: "?", + isARepeat: false, + keyCode: 44 // kVK_ANSI_Slash + ) else { + XCTFail("Failed to construct Cmd+Shift+/ event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftISOAngleBracketDoesNotMatchCommaShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) + ) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "<", + charactersIgnoringModifiers: "<", + isARepeat: false, + keyCode: 10 // kVK_ISO_Section + ) else { + XCTFail("Failed to construct Cmd+Shift+< event from ISO key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftRightBracketCanFallbackByKeyCodeOnNonUSLayouts() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut(action: .nextSurface) { + // Non-US layouts can report "*" (or other symbols) for kVK_ANSI_RightBracket with Shift. + // Shortcut matching should still allow Cmd+Shift+] via keyCode fallback. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct non-US Cmd+Shift+] event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdPhysicalOWithDvorakCharactersTriggersRenameTabShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let renameTabExpectation = expectation(description: "Expected rename tab request for semantic Cmd+R") + var observedRenameTabWindow: NSWindow? + let renameTabToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRenameTabRequested, + object: nil, + queue: nil + ) { notification in + observedRenameTabWindow = notification.object as? NSWindow + renameTabExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(renameTabToken) } + + let switcherExpectation = expectation(description: "Cmd+R should not trigger command palette switcher") + switcherExpectation.isInverted = true + let switcherToken = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(switcherToken) } + + withTemporaryShortcut(action: .renameTab) { + // Dvorak: physical ANSI "O" key can produce "r". + // This should behave as semantic Cmd+R (rename tab), not Cmd+P. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "r", + charactersIgnoringModifiers: "r", + isARepeat: false, + keyCode: 31 // kVK_ANSI_O + ) else { + XCTFail("Failed to construct Dvorak Cmd+R event on physical ANSI O key") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + wait(for: [renameTabExpectation, switcherExpectation], timeout: 1.0) + XCTAssertEqual(observedRenameTabWindow?.windowNumber, window.windowNumber) + } + + func testCmdPhysicalRWithDvorakCharactersTriggersCommandPaletteSwitcher() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let switcherExpectation = expectation(description: "Expected command palette switcher request for semantic Cmd+P") + var observedSwitcherWindow: NSWindow? + let switcherToken = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { notification in + observedSwitcherWindow = notification.object as? NSWindow + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(switcherToken) } + + let renameTabExpectation = expectation(description: "Physical R on Dvorak should not trigger rename tab") + renameTabExpectation.isInverted = true + let renameTabToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRenameTabRequested, + object: nil, + queue: nil + ) { _ in + renameTabExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(renameTabToken) } + + // Dvorak: physical ANSI "R" key can produce "p". + // This should behave as semantic Cmd+P (palette switcher), not Cmd+R. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "p", + charactersIgnoringModifiers: "p", + isARepeat: false, + keyCode: 15 // kVK_ANSI_R + ) else { + XCTFail("Failed to construct Dvorak Cmd+P event on physical ANSI R key") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation, renameTabExpectation], timeout: 1.0) + XCTAssertEqual(observedSwitcherWindow?.windowNumber, window.windowNumber) + } + func testCmdShiftRRequestsRenameWorkspaceInCommandPalette() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -329,11 +1266,14 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { 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() } @@ -370,6 +1310,629 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { 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") @@ -476,12 +2039,43 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { 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: { + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTargets.append(navigationTarget) showFallbackSettingsWindowCallCount += 1 }, activateApplication: { @@ -491,14 +2085,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(showFallbackSettingsWindowCallCount, 1) XCTAssertEqual(activateApplicationCallCount, 1) + XCTAssertEqual(receivedNavigationTargets, [nil]) } func testPresentPreferencesWindowSupportsRepeatedCalls() { var showFallbackSettingsWindowCallCount = 0 var activateApplicationCallCount = 0 + var receivedNavigationTargets: [SettingsNavigationTarget?] = [] AppDelegate.presentPreferencesWindow( - showFallbackSettingsWindow: { + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTargets.append(navigationTarget) showFallbackSettingsWindowCallCount += 1 }, activateApplication: { @@ -507,7 +2104,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) AppDelegate.presentPreferencesWindow( - showFallbackSettingsWindow: { + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTargets.append(navigationTarget) showFallbackSettingsWindowCallCount += 1 }, activateApplication: { @@ -517,16 +2115,54 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { 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 + 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: .keyDown, + with: type, location: .zero, modifierFlags: modifiers, timestamp: ProcessInfo.processInfo.systemUptime, @@ -534,11 +2170,89 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { context: nil, characters: key, charactersIgnoringModifiers: key, - isARepeat: false, + 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 }) @@ -550,3 +2264,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { 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 42ad48b2..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 @@ -762,6 +749,58 @@ final class CJKIMEKeyTextAccumulatorTests: XCTestCase { } } +// 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 @@ -830,3 +869,65 @@ final class GhosttySpaceReleaseRegressionTests: XCTestCase { 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 dfd6d7cc..c67c455f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5,6 +5,7 @@ import WebKit import SwiftUI import ObjectiveC.runtime import Bonsplit +import UserNotifications #if canImport(cmux_DEV) @testable import cmux_DEV @@ -410,6 +411,153 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { 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 @@ -1217,6 +1365,21 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { 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) @@ -1234,6 +1397,537 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { } } +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() { @@ -1302,6 +1996,92 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { 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 @@ -1577,10 +2357,37 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { 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) + } } @MainActor final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { + private final class WKInspectorProbeView: NSView {} + private final class FakeInspector: NSObject { private(set) var showCount = 0 private(set) var closeCount = 0 @@ -1704,46 +2511,122 @@ 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, + 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, + 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()) } } @@ -1902,7 +2785,8 @@ final class BrowserReturnKeyDownRoutingTests: XCTestCase { XCTAssertTrue( shouldDispatchBrowserReturnViaFirstResponderKeyDown( keyCode: 36, - firstResponderIsBrowser: true + firstResponderIsBrowser: true, + flags: [] ) ) } @@ -1911,7 +2795,8 @@ final class BrowserReturnKeyDownRoutingTests: XCTestCase { XCTAssertTrue( shouldDispatchBrowserReturnViaFirstResponderKeyDown( keyCode: 76, - firstResponderIsBrowser: true + firstResponderIsBrowser: true, + flags: [] ) ) } @@ -1920,7 +2805,8 @@ final class BrowserReturnKeyDownRoutingTests: XCTestCase { XCTAssertFalse( shouldDispatchBrowserReturnViaFirstResponderKeyDown( keyCode: 13, - firstResponderIsBrowser: true + firstResponderIsBrowser: true, + flags: [] ) ) } @@ -1929,7 +2815,58 @@ final class BrowserReturnKeyDownRoutingTests: XCTestCase { XCTAssertFalse( shouldDispatchBrowserReturnViaFirstResponderKeyDown( keyCode: 36, - firstResponderIsBrowser: false + 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] ) ) } @@ -1951,6 +2888,52 @@ final class FullScreenShortcutTests: XCTestCase { shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: [.command, .control], chars: "", + keyCode: 3, + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testDoesNotFallbackToANSIWhenLayoutTranslationReturnsNonFCharacter() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, _ in "u" } + ) + ) + } + + func testMatchesCommandControlFWhenCommandAwareLayoutTranslationProvidesF() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, modifierFlags in + modifierFlags.contains(.command) ? "f" : "u" + } + ) + ) + } + + func testMatchesCommandControlFWhenCharsAreControlSequence() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "\u{06}", + keyCode: 3, + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testRejectsPhysicalFWhenCharacterRepresentsDifferentLayoutKey() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "u", keyCode: 3 ) ) @@ -2369,6 +3352,17 @@ final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { ) ) } + + func testConsumesEscapeWhenPaletteIsVisible() { + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [], + chars: "", + keyCode: 53 + ) + ) + } } final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase { @@ -2466,22 +3460,47 @@ final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { } } -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])) +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 testCommandHintUsesIntentionalHoldDelay() { - XCTAssertGreaterThanOrEqual(SidebarCommandHintPolicy.intentionalHoldDelay, 0.25) + 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, @@ -2490,7 +3509,7 @@ final class SidebarCommandHintPolicyTests: XCTestCase { ) XCTAssertFalse( - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: 7, @@ -2499,7 +3518,7 @@ final class SidebarCommandHintPolicyTests: XCTestCase { ) XCTAssertFalse( - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: false, eventWindowNumber: 42, @@ -2508,26 +3527,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) } } @@ -2547,6 +3606,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)) } } @@ -3167,6 +4301,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() { @@ -3239,6 +4425,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() { @@ -4737,7 +5981,8 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { TerminalDirectoryOpenTarget.DetectionEnvironment( homeDirectoryPath: homeDirectoryPath, - fileExistsAtPath: { existingPaths.contains($0) } + fileExistsAtPath: { existingPaths.contains($0) }, + isExecutableFileAtPath: { existingPaths.contains($0) } ) } @@ -4745,6 +5990,7 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { 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", @@ -4775,6 +6021,11 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { 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)) @@ -4792,6 +6043,212 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { } } +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) + } +} + final class BrowserSearchEngineTests: XCTestCase { func testGoogleSearchURL() throws { let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world")) @@ -5566,6 +7023,337 @@ final class NotificationDockBadgeTests: XCTestCase { 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() @@ -5726,6 +7514,118 @@ final class NotificationDockBadgeTests: XCTestCase { } } +@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 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) + } +} + final class MenuBarBadgeLabelFormatterTests: XCTestCase { func testBadgeLabelFormatting() { @@ -6182,8 +8082,56 @@ 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 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), @@ -6212,12 +8160,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), @@ -6236,6 +8190,948 @@ final class WindowBrowserHostViewTests: XCTestCase { let contentPointInHost = host.convert(contentPointInWindow, from: nil) XCTAssertTrue(host.hitTest(contentPointInHost) === child) } + + func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() { + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .cursorUpdate + ) + ) + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .mouseEntered + ) + ) + } + + func testDragHoverEventsPassThroughForSidebarReorderWithoutMouseButtonState() { + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.sidebarTabReorderType], + eventType: .cursorUpdate + ) + ) + } + + func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() { + XCTAssertFalse( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [.fileURL], + eventType: .cursorUpdate + ) + ) + } + + 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 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 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 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 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 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) + } +} + +@MainActor +final class CmuxWebViewDragRoutingTests: XCTestCase { + func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() { + XCTAssertTrue( + CmuxWebView.shouldRejectInternalPaneDrag([ + DragOverlayRoutingPolicy.bonsplitTabTransferType, + NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"), + ]) + ) + } + + func testAllowsRegularExternalFileDrops() { + XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL])) + } +} + +@MainActor +final class BrowserPaneDropRoutingTests: XCTestCase { + func testVerticalZonesFollowAppKitCoordinates() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size), + .top + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size), + .bottom + ) + } + + func 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 @@ -6290,6 +9186,24 @@ final class WindowDragHandleHitTests: XCTestCase { } } + /// 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) @@ -6520,6 +9434,29 @@ final class WindowDragHandleHitTests: XCTestCase { ) } + 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( @@ -6844,6 +9781,18 @@ final class GhosttySurfaceOverlayTests: XCTestCase { } } + 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), @@ -6982,6 +9931,117 @@ final class GhosttySurfaceOverlayTests: XCTestCase { 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" + ) + } + + func testKeyboardCopyModeIndicatorMountsAndUnmounts() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) + + hostedView.setKeyboardCopyModeIndicator(visible: true) + XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator()) + + hostedView.setKeyboardCopyModeIndicator(visible: false) + XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) + } + func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws { #if DEBUG let window = NSWindow( @@ -7181,6 +10241,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( @@ -7421,6 +10529,15 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { @MainActor final class BrowserWindowPortalLifecycleTests: XCTestCase { + private final class TrackingPortalWebView: WKWebView { + private(set) var displayIfNeededCount = 0 + + override func displayIfNeeded() { + displayIfNeededCount += 1 + super.displayIfNeeded() + } + } + private func realizeWindowLayout(_ window: NSWindow) { window.makeKeyAndOrderFront(nil) window.displayIfNeeded() @@ -7429,6 +10546,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), @@ -7459,6 +10589,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), @@ -7539,6 +10723,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), @@ -7609,6 +10833,209 @@ 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 + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + let hiddenDisplayCount = webView.displayIfNeededCount + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertGreaterThanOrEqual(hiddenDisplayCount, initialDisplayCount) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + hiddenDisplayCount, + "Revealing an existing portal-hosted browser should refresh WebKit presentation immediately" + ) + } + + func testVisiblePortalEntryHidesWithoutDetachingDuringTransientAnchorRemovalUntilRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let anchor1 = NSView(frame: anchorFrame) + contentView.addSubview(anchor1) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor1, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor1) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + anchor1.removeFromSuperview() + portal.synchronizeWebViewForAnchor(anchor1) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Visible browser entries should not detach during transient anchor removal") + XCTAssertTrue( + slot.isHidden, + "Transient anchor churn should hide the stale browser slot instead of rendering in the wrong pane" + ) + XCTAssertEqual(portal.debugEntryCount(), 1) + + let displayCountBeforeRebind = webView.displayIfNeededCount + let anchor2 = NSView(frame: anchorFrame) + contentView.addSubview(anchor2) + portal.bind(webView: webView, to: anchor2, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor2) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Rebinding after transient anchor removal should reuse the existing portal slot") + XCTAssertFalse(slot.isHidden) + XCTAssertEqual(portal.debugEntryCount(), 1) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + displayCountBeforeRebind, + "Anchor rebinds should refresh hosted browser presentation even when geometry is unchanged" + ) + } + + func 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), @@ -7633,6 +11060,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 { @@ -7704,6 +11387,56 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { 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 { @@ -7776,6 +11509,58 @@ 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")) @@ -7794,6 +11579,7 @@ final class BrowserExternalNavigationSchemeTests: XCTestCase { 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")) @@ -7802,6 +11588,7 @@ final class BrowserExternalNavigationSchemeTests: XCTestCase { XCTAssertFalse(browserShouldOpenURLExternally(http)) XCTAssertFalse(browserShouldOpenURLExternally(about)) XCTAssertFalse(browserShouldOpenURLExternally(data)) + XCTAssertFalse(browserShouldOpenURLExternally(file)) XCTAssertFalse(browserShouldOpenURLExternally(blob)) XCTAssertFalse(browserShouldOpenURLExternally(javascript)) XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal)) @@ -8068,10 +11855,10 @@ final class TerminalControllerSocketTextChunkTests: XCTestCase { } final class BrowserOmnibarFocusPolicyTests: XCTestCase { - func testReacquiresFocusWhenWebViewSuppressionIsActiveAndNextResponderIsNotAnotherTextField() { + func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() { XCTAssertTrue( browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: true, + desiredOmnibarFocus: true, nextResponderIsOtherTextField: false ) ) @@ -8080,16 +11867,16 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase { func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() { XCTAssertFalse( browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: true, + desiredOmnibarFocus: true, nextResponderIsOtherTextField: true ) ) } - func testDoesNotReacquireFocusWhenWebViewSuppressionIsInactive() { + func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() { XCTAssertFalse( browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: false, + desiredOmnibarFocus: false, nextResponderIsOtherTextField: false ) ) @@ -8188,6 +11975,7 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { return fd } + @MainActor func testSocketListenerHealthRecognizesSocketPath() throws { let path = makeTempSocketPath() let fd = try bindUnixSocket(at: path) @@ -8201,6 +11989,7 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { XCTAssertFalse(health.isHealthy) } + @MainActor func testSocketListenerHealthRejectsRegularFile() throws { let path = makeTempSocketPath() let url = URL(fileURLWithPath: path) @@ -8217,10 +12006,16 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { isRunning: true, acceptLoopAlive: true, socketPathMatches: true, - socketPathExists: true + socketPathExists: true, + socketProbePerformed: true, + socketConnectable: true, + socketConnectErrno: nil ) XCTAssertTrue(health.isHealthy) - XCTAssertEqual(health.failureSignals, []) + XCTAssertTrue(health.failureSignals.isEmpty) + XCTAssertTrue(health.socketProbePerformed) + XCTAssertEqual(health.socketConnectable, true) + XCTAssertNil(health.socketConnectErrno) } func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { @@ -8228,9 +12023,15 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { isRunning: false, acceptLoopAlive: false, socketPathMatches: false, - socketPathExists: false + socketPathExists: false, + socketProbePerformed: false, + socketConnectable: nil, + socketConnectErrno: nil ) XCTAssertFalse(health.isHealthy) + XCTAssertFalse(health.socketProbePerformed) + XCTAssertNil(health.socketConnectable) + XCTAssertNil(health.socketConnectErrno) XCTAssertEqual( health.failureSignals, ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] diff --git a/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 3f85abba..e229b761 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -134,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)") @@ -252,6 +258,52 @@ 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( @@ -529,6 +581,174 @@ final class WorkspaceAppearanceConfigResolutionTests: XCTestCase { } } +@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) @@ -719,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) @@ -1039,6 +1213,12 @@ 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 { @@ -1113,4 +1293,169 @@ final class GhosttyMouseFocusTests: XCTestCase { ) ) } + + // 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])) + } } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index ad9f5b3c..88d8f11c 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -733,3 +733,141 @@ final class SessionPersistenceTests: XCTestCase { ) } } + +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/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 319c350f..96826edf 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( @@ -272,45 +177,3 @@ final class TitlebarControlsHoverPolicyTests: XCTestCase { XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.softButtons.config)) } } - -/// Regression test: ensure new terminal windows are born in full-size content mode so -/// titlebar/content offsets are correct before the first resize. -final class MainWindowLayoutStyleTests: XCTestCase { - 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] - ) - 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. - """ - ) - } - - 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) - } -} diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index 4ed0a584..5f76cb57 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -171,6 +171,154 @@ 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 @@ -275,6 +423,71 @@ 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 @@ -423,6 +636,180 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testCmdOptionPaneSwitchPreservesFindFieldFocus() { + runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: false) + } + + func testCmdCtrlPaneSwitchPreservesFindFieldFocus() { + runFindFocusPersistenceScenario(route: .cmdCtrlLetters, useAutofocusRacePage: false) + } + + func testCmdOptionPaneSwitchPreservesFindFieldFocusDuringPageAutofocusRace() { + runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: true) + } + + private enum FindFocusRoute { + case cmdOptionArrows + case cmdCtrlLetters + } + + private func runFindFocusPersistenceScenario(route: FindFocusRoute, useAutofocusRacePage: Bool) { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + if route == .cmdCtrlLetters { + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + } + launchAndEnsureForeground(app) + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10.0), "Expected main window to exist") + + // Repro setup: split, open browser split, navigate to example.com. + app.typeKey("d", modifierFlags: [.command]) + focusRightPaneForFindScenario(app, route: route) + + app.typeKey("l", modifierFlags: [.command, .shift]) + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+Shift+L") + + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + if useAutofocusRacePage { + app.typeText(autofocusRacePageURL) + } else { + app.typeText("example.com") + } + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + if useAutofocusRacePage { + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0), + "Expected browser navigation to data URL before running find flow. value=\(String(describing: omnibar.value))" + ) + } else { + XCTAssertTrue( + waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0), + "Expected browser navigation to example domain before running find flow. value=\(String(describing: omnibar.value))" + ) + } + + // Left terminal: Cmd+F then type "la". + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "terminal" + }, + "Expected left terminal pane to be focused before terminal find. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("la") + + // Right browser: Cmd+F then type "am". + focusRightPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "right" + && data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "la" + }, + "Expected terminal find query to persist as 'la' after focusing browser pane. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("am") + + if useAutofocusRacePage { + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "#focused", timeout: 5.0), + "Expected autofocus race page to signal focus handoff via URL hash. value=\(String(describing: omnibar.value))" + ) + } + + // Left terminal: typing should keep going into terminal find field. + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "left" + && data["focusedPanelKind"] == "terminal" + && data["browserFindNeedle"] == "am" + }, + "Expected browser find query to persist as 'am' after returning left. data=\(String(describing: loadData()))" + ) + app.typeText("foo") + + // Right browser: typing should keep going into browser find field. + focusRightPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "right" + && data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "lafoo" + }, + "Expected terminal find query to stay focused and become 'lafoo'. data=\(String(describing: loadData()))" + ) + app.typeText("do") + + // Move left once more so the recorder captures browser find state after typing. + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "left" + && data["focusedPanelKind"] == "terminal" + && data["browserFindNeedle"] == "amdo" + }, + "Expected browser find query to stay focused and become 'amdo'. data=\(String(describing: loadData()))" + ) + } + + private func focusLeftPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { + switch route { + case .cmdOptionArrows: + app.typeKey(XCUIKeyboardKey.leftArrow.rawValue, modifierFlags: [.command, .option]) + case .cmdCtrlLetters: + app.typeKey("h", modifierFlags: [.command, .control]) + } + } + + private func focusRightPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { + switch route { + case .cmdOptionArrows: + app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: [.command, .option]) + case .cmdCtrlLetters: + app.typeKey("l", modifierFlags: [.command, .control]) + } + } + + private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let value = (omnibar.value as? String) ?? "" + if value.contains("example.com") || value.contains("example.org") { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + let value = (omnibar.value as? String) ?? "" + return value.contains("example.com") || value.contains("example.org") + } + + private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let value = (omnibar.value as? String) ?? "" + if value.contains(expectedSubstring) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + let value = (omnibar.value as? String) ?? "" + return value.contains(expectedSubstring) + } + + private var autofocusRacePageURL: String { + "data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E" + } + private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { app.launch() XCTAssertTrue( @@ -470,6 +857,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/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index f698b9af..632ad44d 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -190,6 +190,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, @@ -274,6 +427,20 @@ final class MultiWindowNotificationsUITests: XCTestCase { return false } + private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + 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? @@ -287,33 +454,549 @@ final class MultiWindowNotificationsUITests: XCTestCase { return socketCommand("ping") ?? lastResponse } - private func resolveSocketPath(timeout: TimeInterval) -> String? { + private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - for candidate in expectedSocketCandidates() { - guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate) { - return candidate - } + if socketCommand("is_terminal_focused \(surfaceId)") == "true" { + return true } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } - for candidate in expectedSocketCandidates() { - guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate) { + return 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 expectedSocketCandidates() -> [String] { + 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 != socketPath { + if !taggedDebugSocket.isEmpty { candidates.append(taggedDebugSocket) } - return candidates + 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 { @@ -323,20 +1006,21 @@ final class MultiWindowNotificationsUITests: XCTestCase { return socketCommand("ping") == "PONG" } - private func socketCommand(_ cmd: String) -> String? { - if let response = ControlSocketClient(path: socketPath).sendLine(cmd) { + 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) + return socketCommandViaNetcat(cmd, responseTimeout: responseTimeout) } - private func socketCommandViaNetcat(_ cmd: String) -> String? { + 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: "/bin/sh") - let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w 2 2>/dev/null" + 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 outPipe = Pipe() @@ -364,11 +1048,21 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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) { + init(path: String, responseTimeout: TimeInterval = 2.0) { self.path = path + self.responseTimeout = responseTimeout } func sendLine(_ line: String) -> String? { @@ -431,9 +1125,18 @@ final class MultiWindowNotificationsUITests: XCTestCase { } guard wrote else { return nil } + let deadline = Date().addingTimeInterval(responseTimeout) var buf = [UInt8](repeating: 0, count: 4096) var accum = "" - while true { + 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) { 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/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/ghostty b/ghostty index 015b822d..dfd9daa6 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 015b822df22bf50f70af44847b456b43e6d08454 +Subproject commit dfd9daa6af6ab1ecb4325dc2c35b3d3554019f15 diff --git a/ghostty.h b/ghostty.h index 3d397308..b54e84f1 100644 --- a/ghostty.h +++ b/ghostty.h @@ -1108,6 +1108,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..29794d12 --- /dev/null +++ b/scripts/ghosttykit-checksums.txt @@ -0,0 +1,4 @@ +# 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 diff --git a/scripts/reload.sh b/scripts/reload.sh index bf078811..3d99cf57 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -414,15 +414,14 @@ 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.sock" > /tmp/cmux-last-socket-path || true 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/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-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/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_chrome_contrast_regression.py b/tests/test_browser_chrome_contrast_regression.py deleted file mode 100644 index a2552f2f..00000000 --- a/tests/test_browser_chrome_contrast_regression.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression guards for browser chrome contrast in mixed theme setups.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def extract_block(source: str, signature: str) -> str: - start = source.find(signature) - if start < 0: - raise ValueError(f"Missing signature: {signature}") - - brace_start = source.find("{", start) - if brace_start < 0: - raise ValueError(f"Missing opening brace for: {signature}") - - depth = 0 - for idx in range(brace_start, len(source)): - char = source[idx] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[brace_start : idx + 1] - - raise ValueError(f"Unbalanced braces for: {signature}") - - -def main() -> int: - root = repo_root() - view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" - source = view_path.read_text(encoding="utf-8") - failures: list[str] = [] - - try: - browser_panel_view_block = extract_block(source, "struct BrowserPanelView: View") - except ValueError as error: - failures.append(str(error)) - browser_panel_view_block = "" - - try: - resolver_block = extract_block(source, "func resolvedBrowserChromeColorScheme(") - except ValueError as error: - failures.append(str(error)) - resolver_block = "" - - if resolver_block: - if "backgroundColor.isLightColor ? .light : .dark" not in resolver_block: - failures.append( - "resolvedBrowserChromeColorScheme must map luminance to a light/dark ColorScheme" - ) - - try: - chrome_scheme_block = extract_block( - browser_panel_view_block, - "private var browserChromeColorScheme: ColorScheme", - ) - except ValueError as error: - failures.append(str(error)) - chrome_scheme_block = "" - - if chrome_scheme_block and "resolvedBrowserChromeColorScheme(" not in chrome_scheme_block: - failures.append("browserChromeColorScheme must use resolvedBrowserChromeColorScheme") - - try: - omnibar_background_block = extract_block( - browser_panel_view_block, - "private var omnibarPillBackgroundColor: NSColor", - ) - except ValueError as error: - failures.append(str(error)) - omnibar_background_block = "" - - if omnibar_background_block and "for: browserChromeColorScheme" not in omnibar_background_block: - failures.append("omnibar pill background must use browserChromeColorScheme") - - try: - address_bar_block = extract_block( - browser_panel_view_block, - "private var addressBar: some View", - ) - except ValueError as error: - failures.append(str(error)) - address_bar_block = "" - - if address_bar_block and ".environment(\\.colorScheme, browserChromeColorScheme)" not in address_bar_block: - failures.append("addressBar must apply browserChromeColorScheme via environment") - - try: - body_block = extract_block(browser_panel_view_block, "var body: some View") - except ValueError as error: - failures.append(str(error)) - body_block = "" - - if body_block: - if "OmnibarSuggestionsView(" not in body_block: - failures.append("Expected OmnibarSuggestionsView block in BrowserPanelView body") - elif ".environment(\\.colorScheme, browserChromeColorScheme)" not in body_block: - failures.append("Omnibar suggestions must apply browserChromeColorScheme via environment") - - if failures: - print("FAIL: browser chrome contrast regression guards failed") - for failure in failures: - print(f" - {failure}") - return 1 - - print("PASS: browser chrome contrast regression guards are in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_browser_console_errors_cli_output_regression.py b/tests/test_browser_console_errors_cli_output_regression.py deleted file mode 100644 index 40561356..00000000 --- a/tests/test_browser_console_errors_cli_output_regression.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression guard for browser console/errors CLI output formatting. - -Ensures non-JSON `browser console list` and `browser errors list` do not fall -back to unconditional `OK` when logs exist. -""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def extract_block(source: str, signature: str) -> str: - start = source.find(signature) - if start < 0: - raise ValueError(f"Missing signature: {signature}") - brace_start = source.find("{", start) - if brace_start < 0: - raise ValueError(f"Missing opening brace for: {signature}") - depth = 0 - for idx in range(brace_start, len(source)): - char = source[idx] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[brace_start : idx + 1] - raise ValueError(f"Unbalanced braces for: {signature}") - - -def main() -> int: - root = repo_root() - failures: list[str] = [] - - cli_path = root / "CLI" / "cmux.swift" - cli_source = cli_path.read_text(encoding="utf-8") - browser_block = extract_block(cli_source, "private func runBrowserCommand(") - - if "func displayBrowserLogItems(_ value: Any?) -> String?" not in browser_block: - failures.append("runBrowserCommand() is missing displayBrowserLogItems() helper") - else: - helper_block = extract_block(browser_block, "func displayBrowserLogItems(_ value: Any?) -> String?") - if "return \"[\\(level)] \\(text)\"" not in helper_block: - failures.append("displayBrowserLogItems() no longer renders level-prefixed log lines") - if "return \"[error] \\(message)\"" not in helper_block: - failures.append("displayBrowserLogItems() no longer renders concise JS error messages") - if "return displayBrowserValue(dict)" not in helper_block: - failures.append("displayBrowserLogItems() no longer falls back to structured formatting") - - console_block = extract_block(browser_block, 'if subcommand == "console"') - if 'displayBrowserLogItems(payload["entries"])' not in console_block: - failures.append("browser console path no longer formats entries for non-JSON output") - if 'output(payload, fallback: "OK")' in console_block: - failures.append("browser console path regressed to unconditional OK output") - - errors_block = extract_block(browser_block, 'if subcommand == "errors"') - if 'displayBrowserLogItems(payload["errors"])' not in errors_block: - failures.append("browser errors path no longer formats errors for non-JSON output") - if 'output(payload, fallback: "OK")' in errors_block: - failures.append("browser errors path regressed to unconditional OK output") - - if failures: - print("FAIL: browser console/errors CLI output regression guard failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: browser console/errors CLI output regression guard is in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_browser_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_eval_async_wrapper_regression.py b/tests/test_browser_eval_async_wrapper_regression.py deleted file mode 100644 index 4d31948c..00000000 --- a/tests/test_browser_eval_async_wrapper_regression.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression guard for browser eval async wrapping + telemetry injection.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def extract_block(source: str, signature: str) -> str: - start = source.find(signature) - if start < 0: - raise ValueError(f"Missing signature: {signature}") - brace_start = source.find("{", start) - if brace_start < 0: - raise ValueError(f"Missing opening brace for: {signature}") - depth = 0 - for idx in range(brace_start, len(source)): - char = source[idx] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[brace_start : idx + 1] - raise ValueError(f"Unbalanced braces for: {signature}") - - -def extract_span(source: str, start_marker: str, end_marker: str) -> str: - start = source.find(start_marker) - if start < 0: - raise ValueError(f"Missing start marker: {start_marker}") - end = source.find(end_marker, start) - if end < 0: - raise ValueError(f"Missing end marker: {end_marker}") - return source[start:end] - - -def main() -> int: - root = repo_root() - failures: list[str] = [] - - terminal_path = root / "Sources" / "TerminalController.swift" - panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift" - terminal_source = terminal_path.read_text(encoding="utf-8") - panel_source = panel_path.read_text(encoding="utf-8") - - if "preferAsync: Bool = false" not in terminal_source: - failures.append("v2RunJavaScript() no longer exposes preferAsync toggle") - run_js_block = extract_block(terminal_source, "private func v2RunJavaScript(") - if "callAsyncJavaScript" not in run_js_block: - failures.append("v2RunJavaScript() no longer uses callAsyncJavaScript for async JS") - - run_browser_js_block = extract_block(terminal_source, "private func v2RunBrowserJavaScript(") - required_wrapper_tokens = [ - "let asyncFunctionBody =", - "__cmuxMaybeAwait", - "__cmux_t", - "__cmux_v", - "return await __cmuxEvalInFrame();", - "preferAsync: true", - ] - for token in required_wrapper_tokens: - if token not in run_browser_js_block: - failures.append(f"v2RunBrowserJavaScript() missing async eval wrapper token: {token}") - - if "v2BrowserUndefinedSentinel" not in terminal_source: - failures.append("TerminalController is missing undefined sentinel handling") - if "v2BrowserEvalEnvelopeTypeUndefined" not in terminal_source: - failures.append("TerminalController is missing undefined envelope decode constant") - - hook_block = extract_block(terminal_source, "private func v2BrowserEnsureTelemetryHooks(") - if "BrowserPanel.telemetryHookBootstrapScriptSource" not in hook_block: - failures.append("v2BrowserEnsureTelemetryHooks() no longer uses shared BrowserPanel telemetry source") - - if "static let telemetryHookBootstrapScriptSource" not in panel_source: - failures.append("BrowserPanel is missing telemetryHookBootstrapScriptSource") - if "static let dialogTelemetryHookBootstrapScriptSource" not in panel_source: - failures.append("BrowserPanel is missing dialogTelemetryHookBootstrapScriptSource") - - base_script_span = extract_span( - panel_source, - "static let telemetryHookBootstrapScriptSource =", - "static let dialogTelemetryHookBootstrapScriptSource =", - ) - if "window.alert = function(message)" in base_script_span: - failures.append("Document-start telemetry script should not override alert dialogs") - if "window.confirm = function(message)" in base_script_span: - failures.append("Document-start telemetry script should not override confirm dialogs") - if "window.prompt = function(message, defaultValue)" in base_script_span: - failures.append("Document-start telemetry script should not override prompt dialogs") - - panel_init_block = extract_block( - panel_source, - "init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil)", - ) - required_init_tokens = [ - "config.userContentController.addUserScript(", - "source: Self.telemetryHookBootstrapScriptSource", - "injectionTime: .atDocumentStart", - ] - for token in required_init_tokens: - if token not in panel_init_block: - failures.append(f"BrowserPanel init() missing telemetry user-script token: {token}") - - if failures: - print("FAIL: browser eval async wrapper / telemetry injection regression guard failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: browser eval async wrapper / telemetry injection regression guard is in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_browser_eval_cli_output_regression.py b/tests/test_browser_eval_cli_output_regression.py deleted file mode 100644 index 6c2e83da..00000000 --- a/tests/test_browser_eval_cli_output_regression.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression guard for browser eval CLI output formatting. - -Ensures `cmux browser <surface> eval <script>` prints the evaluated value -instead of always printing `OK`. -""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def extract_block(source: str, signature: str) -> str: - start = source.find(signature) - if start < 0: - raise ValueError(f"Missing signature: {signature}") - brace_start = source.find("{", start) - if brace_start < 0: - raise ValueError(f"Missing opening brace for: {signature}") - depth = 0 - for idx in range(brace_start, len(source)): - char = source[idx] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[brace_start : idx + 1] - raise ValueError(f"Unbalanced braces for: {signature}") - - -def main() -> int: - root = repo_root() - failures: list[str] = [] - - cli_path = root / "CLI" / "cmux.swift" - cli_source = cli_path.read_text(encoding="utf-8") - browser_block = extract_block(cli_source, "private func runBrowserCommand(") - - if "func displayBrowserValue(_ value: Any) -> String" not in browser_block: - failures.append("runBrowserCommand() is missing displayBrowserValue() helper") - else: - value_block = extract_block(browser_block, "func displayBrowserValue(_ value: Any) -> String") - if 'dict["__cmux_t"] as? String' not in value_block or 'type == "undefined"' not in value_block: - failures.append("displayBrowserValue() no longer maps __cmux_t=undefined to literal 'undefined'") - required_guards = [ - "if value is NSNull", - "if let string = value as? String", - "if let bool = value as? Bool", - "if let number = value as? NSNumber", - ] - for guard in required_guards: - if guard not in value_block: - failures.append(f"displayBrowserValue() no longer handles: {guard}") - - eval_block = extract_block(browser_block, 'if subcommand == "eval"') - if 'let payload = try client.sendV2(method: "browser.eval"' not in eval_block: - failures.append("browser eval path no longer calls browser.eval v2 method") - if 'if let value = payload["value"]' not in eval_block: - failures.append("browser eval path no longer reads payload value") - if "fallback = displayBrowserValue(value)" not in eval_block: - failures.append("browser eval path no longer formats payload value for CLI output") - if 'output(payload, fallback: "OK")' in eval_block: - failures.append("browser eval path regressed to unconditional OK output") - - if failures: - print("FAIL: browser eval CLI output regression guard failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: browser eval CLI output regression guard is in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) 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 index b66bb505..cea196c5 100644 --- a/tests/test_browser_new_tab_surface_focus_omnibar.py +++ b/tests/test_browser_new_tab_surface_focus_omnibar.py @@ -4,7 +4,8 @@ Regression test: 1. Focusing a blank browser surface should focus the omnibar. 2. Focusing a pane that contains a blank browser should focus the omnibar. 3. If command palette is open, focusing that blank browser surface must not steal input. -4. Cmd+P switcher focusing an existing blank browser surface should focus the omnibar. +4. Cmd+P switcher should list only workspaces, then switching to a workspace with a + focused blank browser should focus the omnibar. """ import json @@ -281,24 +282,47 @@ def main() -> int: workspace_ids.remove(workspace_id) time.sleep(0.3) - # Scenario 4: Cmd+P switcher selecting an existing blank browser surface should focus omnibar. - workspace_id = client.new_workspace() - workspace_ids.append(workspace_id) - client.select_workspace(workspace_id) + # 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") + 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) - switcher_surfaces = client.list_surfaces() - switcher_terminal_id = next((surface_id for _, surface_id, _ in switcher_surfaces if surface_id != switcher_browser_id), None) - if not switcher_terminal_id: - raise cmuxError("Missing terminal surface for Cmd+P switcher scenario") + 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") - client.focus_surface_by_panel(switcher_terminal_id) + 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") @@ -316,11 +340,13 @@ def main() -> int: ): raise cmuxError("Cmd+P did not open command palette switcher") - client.simulate_type("new tab") - time.sleep(0.2) + 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.surface.{workspace_id.lower()}.{switcher_browser_id.lower()}" - switcher_results = command_palette_results(client, window_id, limit=50) + target_command_id = f"switcher.workspace.{target_workspace_id.lower()}" target_index = next( ( idx for idx, row in enumerate(switcher_results) @@ -329,7 +355,7 @@ def main() -> int: None ) if target_index is None: - raise cmuxError(f"Cmd+P switcher did not list target surface command {target_command_id}") + 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}") @@ -358,9 +384,50 @@ def main() -> int: interval_s=0.1 ) if not did_focus_switcher_target: - raise cmuxError("Cmd+P switcher focus to blank browser did not focus omnibar") + raise cmuxError("Cmd+P workspace switch did not restore blank browser omnibar focus") - print("PASS: blank-browser focus paths (surface, pane, and Cmd+P switcher) drive omnibar, while command palette visibility blocks focus stealing") + # 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: 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 707c4a8b..00000000 --- a/tests/test_browser_omnibar_compact_layout_regression.py +++ /dev/null @@ -1,125 +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" - ) - - omnibar_corner_radius = parse_cgfloat_constant(view_source, "omnibarPillCornerRadius") - if omnibar_corner_radius is None: - failures.append("omnibarPillCornerRadius constant is missing") - elif omnibar_corner_radius > 10: - failures.append( - f"omnibarPillCornerRadius regressed to {omnibar_corner_radius:g}; expected <= 10 to keep a squircle profile" - ) - - address_bar_block = extract_block(view_source, "private var addressBar: some View") - if ".padding(.vertical, addressBarVerticalPadding)" not in address_bar_block: - failures.append("addressBar no longer applies compact vertical padding via addressBarVerticalPadding") - - omnibar_field_block = extract_block(view_source, "private var omnibarField: some View") - if omnibar_field_block.count( - "RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)" - ) < 2: - failures.append( - "omnibarField no longer uses continuous rounded-rectangle background+stroke tied to omnibarPillCornerRadius" - ) - - button_bar_block = extract_block(view_source, "private var addressBarButtonBar: some View") - hit_frame_uses = button_bar_block.count("addressBarButtonHitSize") - if hit_frame_uses < 3: - failures.append( - "navigation buttons no longer consistently use addressBarButtonHitSize frames (padding may be lost)" - ) - - extract_block(view_source, "private struct OmnibarAddressButtonStyle: ButtonStyle") - style_body_block = extract_block(view_source, "private struct OmnibarAddressButtonStyleBody: View") - if "configuration.isPressed" not in style_body_block: - failures.append("OmnibarAddressButtonStyleBody is missing pressed-state styling") - if "isHovered" not in style_body_block or ".onHover" not in style_body_block: - failures.append("OmnibarAddressButtonStyleBody is missing hover-state styling") - - style_uses = view_source.count(".buttonStyle(OmnibarAddressButtonStyle())") - if style_uses < 4: - failures.append( - "address bar buttons no longer consistently use OmnibarAddressButtonStyle" - ) - - if failures: - print("FAIL: browser omnibar compact layout regression guards failed") - for failure in failures: - print(f" - {failure}") - return 1 - - print("PASS: browser omnibar compact layout regression guards are in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) 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_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index c63a3111..3b4f7f65 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Regression test for https://github.com/manaflow-ai/cmux/issues/385. -# Ensures self-hosted UI tests are never run for fork pull requests. +# Ensures Depot-hosted UI tests are never run for fork pull requests. set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -9,21 +9,21 @@ WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml" EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository" if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then - echo "FAIL: Missing fork pull_request guard for ui-tests in $WORKFLOW_FILE" + echo "FAIL: Missing fork pull_request guard for tests in $WORKFLOW_FILE" echo "Expected line:" echo " $EXPECTED_IF" exit 1 fi if ! awk ' - /^ tests:/ { in_tests=1; next } + /^ tests-depot:/ { in_tests=1; next } in_tests && /^ [^[:space:]]/ { in_tests=0 } - in_tests && /runs-on: self-hosted/ { saw_self_hosted=1 } + 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_self_hosted && saw_guard) } + END { exit !(saw_depot && saw_guard) } ' "$WORKFLOW_FILE"; then - echo "FAIL: tests block must keep both self-hosted and fork guard" + echo "FAIL: tests-depot block must keep both depot-macos-latest runner and fork guard" exit 1 fi -echo "PASS: tests self-hosted fork guard is present" +echo "PASS: tests-depot Depot runner fork 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_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_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_socket_sentry_scope.py b/tests/test_cli_socket_sentry_scope.py deleted file mode 100644 index 46deeee3..00000000 --- a/tests/test_cli_socket_sentry_scope.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -"""Regression test: CLI socket Sentry telemetry must apply to all commands.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def require(content: str, needle: str, message: str, failures: list[str]) -> None: - if needle not in content: - failures.append(message) - - -def reject(content: str, needle: str, message: str, failures: list[str]) -> None: - if needle in content: - failures.append(message) - - -def main() -> int: - repo_root = get_repo_root() - cli_path = repo_root / "CLI" / "cmux.swift" - if not cli_path.exists(): - print(f"FAIL: missing expected file: {cli_path}") - return 1 - - content = cli_path.read_text(encoding="utf-8") - failures: list[str] = [] - - require( - content, - "private final class CLISocketSentryTelemetry {", - "Missing CLISocketSentryTelemetry definition", - failures, - ) - require( - content, - 'processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||', - "Missing CMUX_CLI_SENTRY_DISABLED kill switch", - failures, - ) - require( - content, - 'processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"', - "Missing backwards-compatible CMUX_CLAUDE_HOOK_SENTRY_DISABLED kill switch", - failures, - ) - require( - content, - "private var shouldEmit: Bool {\n !disabledByEnv\n }", - "Telemetry scope should be command-agnostic (only disabled by env kill switch)", - failures, - ) - require( - content, - 'let crumb = Breadcrumb(level: .info, category: "cmux.cli")', - "Telemetry breadcrumb category should be cmux.cli", - failures, - ) - require( - content, - '"command": command,', - "Base telemetry context must include command name", - failures, - ) - require( - content, - "let cliTelemetry = CLISocketSentryTelemetry(", - "CLI should initialize generic socket telemetry", - failures, - ) - require( - content, - 'cliTelemetry.breadcrumb(\n "socket.connect.attempt",', - "CLI should emit socket.connect.attempt breadcrumb for commands", - failures, - ) - - reject( - content, - "self.enabled = command == \"claude-hook\"", - "Telemetry regressed to claude-hook-only scope", - failures, - ) - reject( - content, - "enabled && !disabledByEnv", - "Telemetry still depends on legacy enabled flag", - failures, - ) - - if failures: - print("FAIL: CLI socket telemetry scope regression(s) detected") - for failure in failures: - print(f"- {failure}") - return 1 - - print("PASS: CLI socket telemetry scope is command-agnostic") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_cli_subcommand_help_regressions.py b/tests/test_cli_subcommand_help_regressions.py deleted file mode 100644 index 1d2b031c..00000000 --- a/tests/test_cli_subcommand_help_regressions.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -"""Regression tests for CLI subcommand help coverage and accuracy.""" - -from __future__ import annotations - -import re -import subprocess -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def require(content: str, needle: str, message: str, failures: list[str]) -> None: - if needle not in content: - failures.append(message) - - -def extract_switch_commands(content: str, start_index: int = 0) -> tuple[set[str], int]: - marker = "switch command {" - marker_index = content.find(marker, start_index) - if marker_index == -1: - return set(), -1 - - open_brace = content.find("{", marker_index) - if open_brace == -1: - return set(), -1 - - depth = 1 - cursor = open_brace + 1 - while cursor < len(content) and depth > 0: - char = content[cursor] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - cursor += 1 - - block = content[open_brace + 1:cursor - 1] - commands: set[str] = set() - collecting_case = False - case_lines: list[str] = [] - - for line in block.splitlines(): - stripped = line.strip() - if stripped.startswith("case "): - collecting_case = True - case_lines = [line] - elif collecting_case: - case_lines.append(line) - - if collecting_case and ":" in line: - case_text = "\n".join(case_lines) - commands.update(re.findall(r'"([^"]+)"', case_text)) - collecting_case = False - case_lines = [] - - return commands, cursor - - -def main() -> int: - repo_root = get_repo_root() - cli_path = repo_root / "CLI" / "cmux.swift" - if not cli_path.exists(): - print(f"FAIL: missing expected file: {cli_path}") - return 1 - - content = cli_path.read_text(encoding="utf-8") - failures: list[str] = [] - - require( - content, - 'if commandArgs.contains("--help") || commandArgs.contains("-h") {', - "Subcommand help pre-dispatch gate is missing", - failures, - ) - require( - content, - 'if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) {', - "Subcommand help dispatch call is missing", - failures, - ) - require( - content, - "print(\"Unknown command '\\(command)'. Run 'cmux help' to see available commands.\")", - "Subcommand help fallback unknown-command line is missing", - failures, - ) - require( - content, - "print(\"Unknown command '\\(command)'. Run 'cmux help' to see available commands.\")\n return", - "Subcommand help fallback must return before command execution", - failures, - ) - - dispatch_commands, next_index = extract_switch_commands(content, 0) - subcommand_usage_commands, _ = extract_switch_commands(content, next_index if next_index != -1 else 0) - if not dispatch_commands: - failures.append("Failed to parse main dispatch switch command list") - if not subcommand_usage_commands: - failures.append("Failed to parse subcommandUsage switch command list") - - missing_help_entries = sorted(dispatch_commands - subcommand_usage_commands) - if missing_help_entries: - failures.append( - "Missing subcommandUsage entries for dispatch command(s): " - + ", ".join(missing_help_entries) - ) - - # Regression checks for concrete help text that previously drifted from dispatch logic. - for needle, message in [ - ('case "help":', "Missing subcommandUsage entry for help"), - ("Usage: cmux help", "help subcommand usage text is missing"), - ("Usage: cmux move-workspace-to-window --workspace <id|ref|index> --window <id|ref|index>", "move-workspace-to-window help must document index handles"), - ("--tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab)", "tab-action help must document CMUX_TAB_ID/CMUX_SURFACE_ID fallback"), - ("--workspace <id|ref|index> Workspace to rename (default: current/$CMUX_WORKSPACE_ID)", "rename-workspace help must document CMUX_WORKSPACE_ID fallback"), - ("text|html|value|count|box|styles|attr: [--selector <css> | <css>]", "browser get help must document --selector"), - ("attr: [--attr <name> | <name>]", "browser get attr help must document --attr"), - ("styles: [--property <name>]", "browser get styles help must document --property"), - ("role: [--name <text>] [--exact] <role>", "browser find role help must document --name/--exact"), - ("text|label|placeholder|alt|title|testid: [--exact] <text>", "browser find text-like help must document --exact"), - ("nth: [--index <n> | <n>] [--selector <css> | <css>]", "browser find nth help must document --index/--selector"), - ("route <pattern> [--abort] [--body <text>]", "browser network route help must document --abort/--body"), - ]: - require(content, needle, message, failures) - - if failures: - print("FAIL: CLI subcommand help regression(s) detected") - for failure in failures: - print(f"- {failure}") - return 1 - - print("PASS: CLI subcommand help coverage and flag/env documentation are present") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_cli_tree_command.py b/tests/test_cli_tree_command.py deleted file mode 100644 index f19484c5..00000000 --- a/tests/test_cli_tree_command.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -"""Regression test: `cmux tree` command wiring and output contract.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def require(content: str, needle: str, message: str, failures: list[str]) -> None: - if needle not in content: - failures.append(message) - - -def main() -> int: - repo_root = get_repo_root() - cli_path = repo_root / "CLI" / "cmux.swift" - controller_path = repo_root / "Sources" / "TerminalController.swift" - if not cli_path.exists(): - print(f"FAIL: missing expected file: {cli_path}") - return 1 - if not controller_path.exists(): - print(f"FAIL: missing expected file: {controller_path}") - return 1 - - content = cli_path.read_text(encoding="utf-8") - controller_content = controller_path.read_text(encoding="utf-8") - failures: list[str] = [] - - require( - content, - 'case "tree":\n try runTreeCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)', - "Missing `tree` command dispatch", - failures, - ) - require( - content, - "tree [--all] [--workspace <id|ref|index>]", - "Top-level usage text missing tree command", - failures, - ) - require( - content, - "Usage: cmux tree [flags]", - "Subcommand help for `cmux tree --help` is missing", - failures, - ) - require( - content, - "Known flags: --all --workspace <id|ref|index> --json", - "Tree flag validation for --all/--workspace is missing", - failures, - ) - require( - content, - "--json Structured JSON output", - "Tree help text should document --json", - failures, - ) - require( - content, - 'print(jsonString(formatIDs(payload, mode: idFormat)))', - "Tree command JSON output should honor --id-format conversion", - failures, - ) - - # Data sources needed for full hierarchy + browser URLs. - for method in [ - 'method: "system.tree"', - 'method: "system.identify"', - 'method: "window.list"', - 'method: "workspace.list"', - 'method: "pane.list"', - 'method: "surface.list"', - 'method: "browser.tab.list"', - 'method: "browser.url.get"', - ]: - require( - content, - method, - f"Tree command is missing expected API call: {method}", - failures, - ) - - # Text tree rendering contract. - for glyph in ['"├── "', '"└── "', '"│ "']: - require( - content, - glyph, - f"Tree output missing box-drawing glyph: {glyph}", - failures, - ) - - for marker in ["[current]", "[selected]", "[focused]", "◀ active", "◀ here"]: - require( - content, - marker, - f"Tree output missing required marker: {marker}", - failures, - ) - - require( - content, - 'surfaceType.lowercased() == "browser"', - "Tree surface rendering should special-case browser surfaces", - failures, - ) - require( - content, - 'let url = surface["url"] as? String', - "Tree surface rendering should include browser URL when available", - failures, - ) - - # Server-side one-shot hierarchy path for performance. - for needle, message in [ - ('case "system.tree":', "Socket router is missing system.tree dispatch"), - ('"system.tree"', "Capabilities list should advertise system.tree"), - ("private func v2SystemTree(params: [String: Any]) -> V2CallResult {", "Missing v2SystemTree implementation"), - ('"active":', "system.tree payload should include focused path"), - ('"caller":', "system.tree payload should include caller path"), - ('"windows":', "system.tree payload should include hierarchy windows"), - ]: - require(controller_content, needle, message, failures) - - if failures: - print("FAIL: cmux tree command regression(s) detected") - for failure in failures: - print(f"- {failure}") - return 1 - - print("PASS: cmux tree command wiring and output contract are present") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_cli_version_commit_metadata.py b/tests/test_cli_version_commit_metadata.py deleted file mode 100644 index 3029fe0d..00000000 --- a/tests/test_cli_version_commit_metadata.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -"""Regression test: CLI version output wiring keeps commit metadata support.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def require(content: str, needle: str, message: str, failures: list[str]) -> None: - if needle not in content: - failures.append(message) - - -def main() -> int: - repo_root = get_repo_root() - cli_path = repo_root / "CLI" / "cmux.swift" - if not cli_path.exists(): - print(f"FAIL: missing expected file: {cli_path}") - return 1 - - content = cli_path.read_text(encoding="utf-8") - failures: list[str] = [] - - require( - content, - 'let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }', - "versionSummary no longer reads CMUXCommit metadata", - failures, - ) - require( - content, - 'return "\\(baseSummary) [\\(commit)]"', - "versionSummary no longer appends commit metadata", - failures, - ) - require( - content, - 'if let commit = dictionary["CMUXCommit"] as? String,', - "Info.plist parsing no longer reads CMUXCommit", - failures, - ) - require( - content, - "if let commit = gitCommitHash(at: current) {", - "Project fallback no longer probes git commit hash", - failures, - ) - require( - content, - '["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]', - "Git commit probe command changed unexpectedly", - failures, - ) - require( - content, - 'normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"])', - "Environment commit fallback (CMUX_COMMIT) is missing", - failures, - ) - - if failures: - print("FAIL: CLI version commit metadata regression(s) detected") - for failure in failures: - print(f"- {failure}") - return 1 - - print("PASS: CLI version commit metadata wiring is intact") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_cli_version_memory_guard.py b/tests/test_cli_version_memory_guard.py new file mode 100644 index 00000000..0a1c5bd1 --- /dev/null +++ b/tests/test_cli_version_memory_guard.py @@ -0,0 +1,196 @@ +#!/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) + + 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_command_palette_socket_restart_command.py b/tests/test_command_palette_socket_restart_command.py deleted file mode 100644 index 9bcd258d..00000000 --- a/tests/test_command_palette_socket_restart_command.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -"""Regression test for command-palette socket-listener restart command wiring.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def read_text(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def require(content: str, needle: str, message: str, failures: list[str]) -> None: - if needle not in content: - failures.append(message) - - -def main() -> int: - repo_root = get_repo_root() - content_view_path = repo_root / "Sources" / "ContentView.swift" - app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" - - missing_paths = [ - str(path) - for path in [content_view_path, app_delegate_path] - if not path.exists() - ] - if missing_paths: - print("Missing expected files:") - for path in missing_paths: - print(f" - {path}") - return 1 - - content_view = read_text(content_view_path) - app_delegate = read_text(app_delegate_path) - - failures: list[str] = [] - - require( - content_view, - 'commandId: "palette.restartSocketListener"', - "Missing `palette.restartSocketListener` command contribution", - failures, - ) - require( - content_view, - 'title: constant("Restart CLI Listener")', - "Missing `Restart CLI Listener` command title", - failures, - ) - require( - content_view, - 'registry.register(commandId: "palette.restartSocketListener") {', - "Missing command handler registration for `palette.restartSocketListener`", - failures, - ) - require( - content_view, - "AppDelegate.shared?.restartSocketListener(nil)", - "Socket restart command handler does not call `AppDelegate.restartSocketListener`", - failures, - ) - - require( - app_delegate, - "@objc func restartSocketListener(_ sender: Any?) {", - "Missing `AppDelegate.restartSocketListener` action", - failures, - ) - require( - app_delegate, - "private func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? {", - "Missing shared socket listener configuration helper", - failures, - ) - require( - app_delegate, - 'restartSocketListenerIfEnabled(source: "menu.command")', - "`restartSocketListener` no longer delegates to restart helper", - failures, - ) - require( - app_delegate, - "TerminalController.shared.stop()", - "`restartSocketListenerIfEnabled` no longer stops current listener before restart", - failures, - ) - require( - app_delegate, - "TerminalController.shared.start(tabManager: tabManager, socketPath: config.path, accessMode: config.mode)", - "`restartSocketListenerIfEnabled` no longer starts listener with current settings", - failures, - ) - - if failures: - print("FAIL: command-palette socket restart command regression(s) detected") - for failure in failures: - print(f"- {failure}") - return 1 - - print("PASS: command-palette socket restart command wiring is intact") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_command_palette_update_commands.py b/tests/test_command_palette_update_commands.py deleted file mode 100755 index f5035037..00000000 --- a/tests/test_command_palette_update_commands.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -"""Regression test for command-palette update command wiring.""" - -from __future__ import annotations - -import re -import subprocess -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def read_text(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def expect_regex(content: str, pattern: str, message: str, failures: list[str]) -> None: - if re.search(pattern, content, flags=re.DOTALL) is None: - failures.append(message) - - -def main() -> int: - repo_root = get_repo_root() - content_view_path = repo_root / "Sources" / "ContentView.swift" - app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" - controller_path = repo_root / "Sources" / "Update" / "UpdateController.swift" - - missing_paths = [ - str(path) - for path in [content_view_path, app_delegate_path, controller_path] - if not path.exists() - ] - if missing_paths: - print("Missing expected files:") - for path in missing_paths: - print(f" - {path}") - return 1 - - content_view = read_text(content_view_path) - app_delegate = read_text(app_delegate_path) - controller = read_text(controller_path) - - failures: list[str] = [] - - expect_regex( - content_view, - r'static\s+let\s+updateHasAvailable\s*=\s*"update\.hasAvailable"', - "Missing `CommandPaletteContextKeys.updateHasAvailable`", - failures, - ) - expect_regex( - content_view, - r'if\s+case\s+\.updateAvailable\s*=\s*updateViewModel\.effectiveState\s*\{\s*snapshot\.setBool\(CommandPaletteContextKeys\.updateHasAvailable,\s*true\)\s*\}', - "Command palette context no longer tracks update-available state", - failures, - ) - expect_regex( - content_view, - r'commandId:\s*"palette\.applyUpdateIfAvailable".*?title:\s*constant\("Apply Update \(If Available\)"\).*?keywords:\s*\[[^\]]*"apply"[^\]]*"install"[^\]]*"update"[^\]]*"available"[^\]]*\].*?when:\s*\{\s*\$0\.bool\(CommandPaletteContextKeys\.updateHasAvailable\)\s*\}', - "Missing or incomplete `palette.applyUpdateIfAvailable` contribution visibility gating", - failures, - ) - expect_regex( - content_view, - r'commandId:\s*"palette\.attemptUpdate".*?title:\s*constant\("Attempt Update"\).*?keywords:\s*\[[^\]]*"attempt"[^\]]*"check"[^\]]*"update"[^\]]*\]', - "Missing or incomplete `palette.attemptUpdate` contribution", - failures, - ) - expect_regex( - content_view, - r'registry\.register\(commandId:\s*"palette\.applyUpdateIfAvailable"\)\s*\{\s*AppDelegate\.shared\?\.applyUpdateIfAvailable\(nil\)\s*\}', - "Missing handler registration for `palette.applyUpdateIfAvailable`", - failures, - ) - expect_regex( - content_view, - r'registry\.register\(commandId:\s*"palette\.attemptUpdate"\)\s*\{\s*AppDelegate\.shared\?\.attemptUpdate\(nil\)\s*\}', - "Missing handler registration for `palette.attemptUpdate`", - failures, - ) - - expect_regex( - app_delegate, - r'@objc\s+func\s+applyUpdateIfAvailable\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.installUpdate\(\)\s*\}', - "`AppDelegate.applyUpdateIfAvailable` is missing or does not call `updateController.installUpdate()`", - failures, - ) - expect_regex( - app_delegate, - r'@objc\s+func\s+attemptUpdate\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.attemptUpdate\(\)\s*\}', - "`AppDelegate.attemptUpdate` is missing or does not call `updateController.attemptUpdate()`", - failures, - ) - - expect_regex( - controller, - r'func\s+attemptUpdate\(\)\s*\{', - "`UpdateController.attemptUpdate()` is missing", - failures, - ) - if "state.confirm()" not in controller: - failures.append("`UpdateController.attemptUpdate()` no longer auto-confirms update installation") - if "checkForUpdates()" not in controller: - failures.append("`UpdateController.attemptUpdate()` no longer triggers a check before install") - - if failures: - print("FAIL: command-palette update command regression(s) detected") - for failure in failures: - print(f"- {failure}") - return 1 - - print("PASS: command-palette update commands expose apply + attempt wiring") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) 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_focus_panel_reentrant_guard_regression.py b/tests/test_focus_panel_reentrant_guard_regression.py deleted file mode 100644 index fbe2a5c3..00000000 --- a/tests/test_focus_panel_reentrant_guard_regression.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression checks for re-entrant terminal focus guard. - -Guards the fix for split-drag focus churn where: -becomeFirstResponder -> onFocus -> Workspace.focusPanel -> refocus side-effects -could repeatedly re-enter and spike CPU. -""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def main() -> int: - root = repo_root() - failures: list[str] = [] - - workspace_path = root / "Sources" / "Workspace.swift" - workspace_source = workspace_path.read_text(encoding="utf-8") - - required_workspace_snippets = [ - "enum FocusPanelTrigger {", - "case terminalFirstResponder", - "trigger: FocusPanelTrigger = .standard", - "let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged", - "if let targetPaneId, !shouldSuppressReentrantRefocus {", - "reason=firstResponderAlreadyConverged", - ] - for snippet in required_workspace_snippets: - if snippet not in workspace_source: - failures.append(f"Workspace focus guard missing snippet: {snippet}") - - workspace_content_view_path = root / "Sources" / "WorkspaceContentView.swift" - workspace_content_view_source = workspace_content_view_path.read_text(encoding="utf-8") - focus_callback_snippet = "workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)" - if focus_callback_snippet not in workspace_content_view_source: - failures.append( - "WorkspaceContentView terminal onFocus callback no longer passes .terminalFirstResponder trigger" - ) - - if failures: - print("FAIL: focus-panel re-entrant guard regression checks failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: focus-panel re-entrant guard is in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_issue_494_sleep_wake_git_branch_recovery.py b/tests/test_issue_494_sleep_wake_git_branch_recovery.py deleted file mode 100644 index 9830b36c..00000000 --- a/tests/test_issue_494_sleep_wake_git_branch_recovery.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -"""Regression guard for issue #494 (post-wake sidebar git updates freezing).""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def read_text(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def require(content: str, needle: str, message: str, failures: list[str]) -> None: - if needle not in content: - failures.append(message) - - -def main() -> int: - repo_root = get_repo_root() - zsh_path = repo_root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh" - bash_path = repo_root / "Resources" / "shell-integration" / "cmux-bash-integration.bash" - app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" - - required_paths = [zsh_path, bash_path, app_delegate_path] - missing_paths = [str(path) for path in required_paths if not path.exists()] - if missing_paths: - print("Missing expected files:") - for path in missing_paths: - print(f" - {path}") - return 1 - - zsh_content = read_text(zsh_path) - bash_content = read_text(bash_path) - app_delegate = read_text(app_delegate_path) - - failures: list[str] = [] - - require( - zsh_content, - "_CMUX_GIT_JOB_STARTED_AT", - "zsh integration is missing git probe start tracking", - failures, - ) - require( - zsh_content, - "_CMUX_PR_JOB_STARTED_AT", - "zsh integration is missing PR probe start tracking", - failures, - ) - require( - zsh_content, - "_CMUX_ASYNC_JOB_TIMEOUT", - "zsh integration is missing async probe timeout guard", - failures, - ) - require( - zsh_content, - "now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT", - "zsh integration no longer clears stale git probe PID after timeout", - failures, - ) - require( - zsh_content, - "now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT", - "zsh integration no longer clears stale PR probe PID after timeout", - failures, - ) - require( - zsh_content, - "ncat -w 1 -U \"$CMUX_SOCKET_PATH\" --send-only", - "zsh integration missing ncat socket timeout", - failures, - ) - require( - zsh_content, - "socat -T 1 - \"UNIX-CONNECT:$CMUX_SOCKET_PATH\"", - "zsh integration missing socat socket timeout", - failures, - ) - - require( - bash_content, - "_CMUX_GIT_JOB_STARTED_AT", - "bash integration is missing git probe start tracking", - failures, - ) - require( - bash_content, - "_CMUX_PR_JOB_STARTED_AT", - "bash integration is missing PR probe start tracking", - failures, - ) - require( - bash_content, - "_CMUX_ASYNC_JOB_TIMEOUT", - "bash integration is missing async probe timeout guard", - failures, - ) - require( - bash_content, - "now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT", - "bash integration no longer clears stale git probe PID after timeout", - failures, - ) - require( - bash_content, - "now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT", - "bash integration no longer clears stale PR probe PID after timeout", - failures, - ) - require( - bash_content, - "ncat -w 1 -U \"$CMUX_SOCKET_PATH\" --send-only", - "bash integration missing ncat socket timeout", - failures, - ) - require( - bash_content, - "socat -T 1 - \"UNIX-CONNECT:$CMUX_SOCKET_PATH\"", - "bash integration missing socat socket timeout", - failures, - ) - - require( - app_delegate, - "NSWorkspace.didWakeNotification", - "AppDelegate is missing wake observer for socket listener recovery", - failures, - ) - require( - app_delegate, - "restartSocketListenerIfEnabled(source: \"workspace.didWake\")", - "Wake observer no longer re-arms the socket listener", - failures, - ) - require( - app_delegate, - "private func restartSocketListenerIfEnabled(source: String)", - "Missing shared socket-listener restart helper", - failures, - ) - - if failures: - print("FAIL: issue #494 regression(s) detected") - for failure in failures: - print(f"- {failure}") - return 1 - - print("PASS: issue #494 sleep/wake recovery guards are present") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_issue_582_sidebar_git_branch_fast_path.py b/tests/test_issue_582_sidebar_git_branch_fast_path.py deleted file mode 100644 index 9718189f..00000000 --- a/tests/test_issue_582_sidebar_git_branch_fast_path.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -"""Regression guard for issue #582 (sidebar git branch updates stalling).""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def extract_function(content: str, signature: str) -> str: - start = content.find(signature) - if start < 0: - return "" - brace = content.find("{", start) - if brace < 0: - return "" - depth = 0 - for idx in range(brace, len(content)): - ch = content[idx] - if ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - return content[start : idx + 1] - return "" - - -def require(content: str, needle: str, message: str, failures: list[str]) -> None: - if needle not in content: - failures.append(message) - - -def main() -> int: - repo_root = get_repo_root() - terminal_controller_path = repo_root / "Sources" / "TerminalController.swift" - if not terminal_controller_path.exists(): - print(f"Missing expected file: {terminal_controller_path}") - return 1 - - terminal_controller = terminal_controller_path.read_text(encoding="utf-8") - report_body = extract_function(terminal_controller, "private func reportGitBranch(_ args: String) -> String") - clear_body = extract_function(terminal_controller, "private func clearGitBranch(_ args: String) -> String") - - failures: list[str] = [] - - if not report_body: - failures.append("Unable to locate reportGitBranch implementation") - if not clear_body: - failures.append("Unable to locate clearGitBranch implementation") - - if report_body: - require( - report_body, - "if let scope = Self.explicitSocketScope(options: parsed.options)", - "reportGitBranch is missing explicit-scope fast path", - failures, - ) - require( - report_body, - "DispatchQueue.main.async", - "reportGitBranch no longer schedules explicit-scope updates with main.async", - failures, - ) - require( - report_body, - "tab.updatePanelGitBranch(panelId: scope.panelId", - "reportGitBranch fast path no longer writes branch state to the scoped panel", - failures, - ) - require( - report_body, - "DispatchQueue.main.sync", - "reportGitBranch lost sync fallback path for non-explicit/manual calls", - failures, - ) - - if clear_body: - require( - clear_body, - "if let scope = Self.explicitSocketScope(options: parsed.options)", - "clearGitBranch is missing explicit-scope fast path", - failures, - ) - require( - clear_body, - "DispatchQueue.main.async", - "clearGitBranch no longer schedules explicit-scope clears with main.async", - failures, - ) - require( - clear_body, - "tab.clearPanelGitBranch(panelId: scope.panelId)", - "clearGitBranch fast path no longer clears branch state for the scoped panel", - failures, - ) - require( - clear_body, - "DispatchQueue.main.sync", - "clearGitBranch lost sync fallback path for non-explicit/manual calls", - failures, - ) - - if failures: - print("FAIL: issue #582 regression(s) detected") - for failure in failures: - print(f"- {failure}") - return 1 - - print("PASS: issue #582 git branch socket fast path guards are present") - return 0 - - -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 685480eb..00000000 --- a/tests/test_lint_swiftui_patterns.py +++ /dev/null @@ -1,190 +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 re -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 check_command_palette_caret_tint(repo_root: Path) -> List[str]: - """Ensure command palette text inputs keep a white caret tint.""" - content_view = repo_root / "Sources" / "ContentView.swift" - if not content_view.exists(): - return [f"Missing expected file: {content_view}"] - - try: - content = content_view.read_text() - except Exception as e: - return [f"Could not read {content_view}: {e}"] - - checks = [ - ( - "search input", - r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P<body>.*?)" - r"\.focused\(\$isCommandPaletteSearchFocused\)", - ), - ( - "rename input", - r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P<body>.*?)" - r"\.focused\(\$isCommandPaletteRenameFocused\)", - ), - ] - - violations: List[str] = [] - for label, pattern in checks: - match = re.search(pattern, content, flags=re.DOTALL) - if not match: - violations.append( - f"Could not locate command palette {label} TextField block in Sources/ContentView.swift" - ) - continue - - body = match.group("body") - if ".tint(.white)" not in body: - violations.append( - f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift" - ) - - return violations - - -def main(): - """Run the lint checks.""" - repo_root = get_repo_root() - swift_files = find_swift_files(repo_root) - - print(f"Checking {len(swift_files)} Swift files for performance issues...") - - # Check for auto-updating Text styles - style_violations = check_autoupdating_text_styles(swift_files) - tint_violations = check_command_palette_caret_tint(repo_root) - has_failures = False - - 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 style_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() - - 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 linted SwiftUI pattern regressions found") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_microphone_access_metadata.py b/tests/test_microphone_access_metadata.py deleted file mode 100644 index 595aa542..00000000 --- a/tests/test_microphone_access_metadata.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -"""Regression test: cmux advertises and allows microphone access.""" - -from __future__ import annotations - -import plistlib -import subprocess -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def load_plist(path: Path, failures: list[str]) -> dict: - if not path.exists(): - failures.append(f"Missing expected file: {path}") - return {} - with path.open("rb") as f: - return plistlib.load(f) - - -def main() -> int: - repo_root = get_repo_root() - failures: list[str] = [] - - info = load_plist(repo_root / "Resources" / "Info.plist", failures) - entitlements = load_plist(repo_root / "cmux.entitlements", failures) - - mic_usage = info.get("NSMicrophoneUsageDescription") - if not isinstance(mic_usage, str) or not mic_usage.strip(): - failures.append( - "Resources/Info.plist must define a non-empty NSMicrophoneUsageDescription" - ) - elif mic_usage.strip() != "A program running within cmux would like to use your microphone.": - failures.append( - "Resources/Info.plist NSMicrophoneUsageDescription should match the Ghostty-style wording" - ) - - if entitlements.get("com.apple.security.device.audio-input") is not True: - failures.append( - "cmux.entitlements must set com.apple.security.device.audio-input to true" - ) - - if failures: - print("FAIL: microphone access metadata regression(s) detected") - for failure in failures: - print(f"- {failure}") - return 1 - - print("PASS: microphone usage description and entitlement are present") - return 0 - - -if __name__ == "__main__": - raise SystemExit(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 index 6119033a..b2b98a51 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -33,7 +33,10 @@ def run_wrapper( 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) @@ -85,6 +88,13 @@ case "$key" in 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 ;; @@ -113,6 +123,12 @@ 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" @@ -120,6 +136,10 @@ exit 0 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) @@ -136,6 +156,11 @@ exit 0 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: @@ -143,6 +168,7 @@ exit 0 result = subprocess.run( ["/bin/bash", str(wrapper), *args], + cwd=tmp, env=env, capture_output=True, text=True, @@ -213,6 +239,95 @@ def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None: 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" @@ -282,6 +397,149 @@ def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None: 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( @@ -312,10 +570,22 @@ def main() -> int: 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) 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_indicator_default.py b/tests/test_sidebar_indicator_default.py deleted file mode 100644 index 4cf5d77a..00000000 --- a/tests/test_sidebar_indicator_default.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -""" -Regression test for the default sidebar active workspace indicator style. -""" - -from __future__ import annotations - -import re -import subprocess -import sys -from pathlib import Path - - -def get_repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path.cwd() - - -def main() -> int: - repo_root = get_repo_root() - tab_manager = repo_root / "Sources" / "TabManager.swift" - - if not tab_manager.exists(): - print(f"FAIL: Missing file {tab_manager}") - return 1 - - content = tab_manager.read_text(encoding="utf-8") - pattern = r"static let defaultStyle:\s*SidebarActiveTabIndicatorStyle\s*=\s*\.leftRail\b" - - if re.search(pattern, content) is None: - rel = tab_manager.relative_to(repo_root) - print(f"FAIL: Expected default style `.leftRail` in {rel}") - return 1 - - print("PASS: sidebar indicator default style is left rail") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) 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_terminal_resize_portal_regressions.py b/tests/test_terminal_resize_portal_regressions.py deleted file mode 100644 index f42f7af9..00000000 --- a/tests/test_terminal_resize_portal_regressions.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression checks for terminal tiny-pane resize/overflow fixes. - -Guards the key invariants for issue #348: -1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds. -2) Surface sizing must prefer live bounds over stale pending values when available. -""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def extract_block(source: str, signature: str) -> str: - start = source.find(signature) - if start < 0: - raise ValueError(f"Missing signature: {signature}") - brace_start = source.find("{", start) - if brace_start < 0: - raise ValueError(f"Missing opening brace for: {signature}") - - depth = 0 - for idx in range(brace_start, len(source)): - char = source[idx] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[brace_start : idx + 1] - raise ValueError(f"Unbalanced braces for: {signature}") - - -def main() -> int: - root = repo_root() - failures: list[str] = [] - - portal_path = root / "Sources" / "TerminalWindowPortal.swift" - portal_source = portal_path.read_text(encoding="utf-8") - - if "hostView.layer?.masksToBounds = true" not in portal_source: - failures.append("WindowTerminalPortal init no longer enables hostView layer clipping") - if "hostView.postsFrameChangedNotifications = true" not in portal_source: - failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications") - if "hostView.postsBoundsChangedNotifications = true" not in portal_source: - failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications") - - if "private func synchronizeLayoutHierarchy()" not in portal_source: - failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()") - if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source: - failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()") - if "hostedView.reconcileGeometryNow()" not in extract_block( - portal_source, - "func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)", - ): - failures.append("bind() no longer pre-reconciles hosted geometry before attach") - - sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)") - for required in [ - "let hostBounds = hostView.bounds", - "let clampedFrame = frameInHost.intersection(hostBounds)", - "let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost", - "hostedView.reconcileGeometryNow()", - "hostedView.refreshSurfaceNow()", - ]: - if required not in sync_block: - failures.append(f"terminal portal sync missing: {required}") - - if ( - "scheduleDeferredFullSynchronizeAll()" not in sync_block - and "scheduleTransientRecoveryRetryIfNeeded(" not in sync_block - ): - failures.append( - "terminal portal sync no longer schedules deferred recovery for transient geometry states" - ) - - terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift" - terminal_view_source = terminal_view_path.read_text(encoding="utf-8") - - resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize") - bounds_index = resolved_block.find("let currentBounds = bounds.size") - pending_index = resolved_block.find("if let pending = pendingSurfaceSize") - if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index: - failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize") - - update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)") - if "let size = resolvedSurfaceSize(preferred: size)" not in update_block: - failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()") - - if failures: - print("FAIL: terminal resize/portal regression guards failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: terminal resize/portal regression guards are in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) 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/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_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_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 c4b8f5cc..fa452db1 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit c4b8f5cc3def0a44c1c3634d4f358a66fd956606 +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/api/feedback/route.ts b/web/app/api/feedback/route.ts new file mode 100644 index 00000000..33256634 --- /dev/null +++ b/web/app/api/feedback/route.ts @@ -0,0 +1,340 @@ +import { checkRateLimit } from "@vercel/firewall"; +import { NextResponse } from "next/server"; +import { Resend } from "resend"; +import { z } from "zod"; + +import { env } from "@/app/env"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const feedbackRecipient = "feedback@manaflow.com"; +const maxAttachmentCount = 10; +const maxAttachmentBytes = 4 * 1024 * 1024; +// Keep multipart requests below Vercel Functions' 4.5 MB request-body limit. +const maxTotalAttachmentBytes = 4 * 1024 * 1024; +const allowedImageTypes = new Set([ + "image/gif", + "image/heic", + "image/heif", + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", +]); + +const feedbackSchema = z.object({ + email: z.string().trim().email().max(320), + message: z.string().trim().min(1).max(4000), + appVersion: z.string().trim().max(120).optional().default(""), + appBuild: z.string().trim().max(120).optional().default(""), + appCommit: z.string().trim().max(120).optional().default(""), + bundleIdentifier: z.string().trim().max(200).optional().default(""), + osVersion: z.string().trim().max(200).optional().default(""), + locale: z.string().trim().max(120).optional().default(""), +}); + +type PreparedAttachment = { + content: Buffer; + contentType: string; + filename: string; + size: number; +}; + +export async function POST(request: Request) { + const feedbackConfig = resolveFeedbackConfig(); + if (!feedbackConfig) { + return jsonError("Feedback endpoint is not configured", 503); + } + + if (process.env.VERCEL === "1") { + const { error, rateLimited } = await checkRateLimit( + feedbackConfig.rateLimitId, + { request }, + ); + + if (rateLimited || error === "blocked") { + return jsonError("Rate limit exceeded", 429); + } + + if (error === "not-found") { + console.error( + "feedback.route.rate_limit_not_found", + feedbackConfig.rateLimitId, + ); + } else if (error) { + console.error("feedback.route.rate_limit_error", error); + } + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return jsonError("Invalid multipart payload", 400); + } + + const parsed = feedbackSchema.safeParse({ + email: getString(formData, "email"), + message: getString(formData, "message"), + appVersion: getString(formData, "appVersion"), + appBuild: getString(formData, "appBuild"), + appCommit: getString(formData, "appCommit"), + bundleIdentifier: getString(formData, "bundleIdentifier"), + osVersion: getString(formData, "osVersion"), + locale: getString(formData, "locale"), + }); + + if (!parsed.success) { + return jsonError("Invalid feedback payload", 400); + } + + const attachmentsResult = await prepareAttachments( + formData.getAll("attachments"), + ); + if ("errorResponse" in attachmentsResult) { + return attachmentsResult.errorResponse; + } + + const { appBuild, appCommit, appVersion, bundleIdentifier, email, locale, message, osVersion } = + parsed.data; + const subject = buildSubject(email, message, appVersion); + const attachments = attachmentsResult.attachments; + const resend = new Resend(feedbackConfig.resendApiKey); + + const { error } = await resend.emails.send({ + from: `Manaflow <${feedbackConfig.fromEmail}>`, + to: [feedbackRecipient], + replyTo: email, + subject, + text: buildTextBody({ + email, + message, + appVersion, + appBuild, + appCommit, + bundleIdentifier, + osVersion, + locale, + attachments, + }), + html: buildHtmlBody({ + email, + message, + appVersion, + appBuild, + appCommit, + bundleIdentifier, + osVersion, + locale, + attachments, + }), + attachments: attachments.map((attachment) => ({ + content: attachment.content, + contentType: attachment.contentType, + filename: attachment.filename, + })), + }); + + if (error) { + console.error("feedback.route.resend_failed", error); + return jsonError("Failed to send feedback", 502); + } + + return NextResponse.json( + { ok: true }, + { + headers: { + "Cache-Control": "no-store", + }, + }, + ); +} + +function resolveFeedbackConfig() { + const resendApiKey = env.RESEND_API_KEY; + const fromEmail = env.CMUX_FEEDBACK_FROM_EMAIL; + const rateLimitId = env.CMUX_FEEDBACK_RATE_LIMIT_ID; + + if (!resendApiKey || !fromEmail || !rateLimitId) { + return null; + } + + return { + resendApiKey, + fromEmail, + rateLimitId, + }; +} + +function getString(formData: FormData, key: string) { + const value = formData.get(key); + return typeof value === "string" ? value.trim() : ""; +} + +async function prepareAttachments(values: FormDataEntryValue[]) { + const files = values.filter( + (value): value is File => value instanceof File && value.name.length > 0, + ); + + if (files.length > maxAttachmentCount) { + return { + errorResponse: jsonError("Too many images attached", 400), + }; + } + + let totalSize = 0; + const attachments: PreparedAttachment[] = []; + + for (const file of files) { + if (!allowedImageTypes.has(file.type)) { + return { + errorResponse: jsonError("Unsupported image attachment type", 415), + }; + } + + if (file.size > maxAttachmentBytes) { + return { + errorResponse: jsonError("Image attachment is too large", 413), + }; + } + + totalSize += file.size; + if (totalSize > maxTotalAttachmentBytes) { + return { + errorResponse: jsonError("Total image attachment size is too large", 413), + }; + } + + attachments.push({ + content: Buffer.from(await file.arrayBuffer()), + contentType: file.type, + filename: sanitizeFilename(file.name), + size: file.size, + }); + } + + return { attachments }; +} + +function buildSubject(email: string, message: string, appVersion: string) { + const firstNonEmptyLine = + message + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "Feedback"; + const summary = + firstNonEmptyLine.length > 72 + ? `${firstNonEmptyLine.slice(0, 69)}...` + : firstNonEmptyLine; + const versionSuffix = appVersion ? ` (v${appVersion})` : ""; + + return `cmux feedback from ${email}${versionSuffix}: ${summary}`; +} + +function buildTextBody(input: { + email: string; + message: string; + appVersion: string; + appBuild: string; + appCommit: string; + bundleIdentifier: string; + osVersion: string; + locale: string; + attachments: PreparedAttachment[]; +}) { + const attachmentLines = + input.attachments.length === 0 + ? "Attachments: none" + : [ + "Attachments:", + ...input.attachments.map( + (attachment) => + `- ${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`, + ), + ].join("\n"); + + return [ + `From: ${input.email}`, + `App version: ${input.appVersion || "unknown"}`, + `App build: ${input.appBuild || "unknown"}`, + `App commit: ${input.appCommit || "unknown"}`, + `Bundle identifier: ${input.bundleIdentifier || "unknown"}`, + `macOS: ${input.osVersion || "unknown"}`, + `Locale: ${input.locale || "unknown"}`, + attachmentLines, + "", + "Message:", + input.message, + ].join("\n"); +} + +function buildHtmlBody(input: { + email: string; + message: string; + appVersion: string; + appBuild: string; + appCommit: string; + bundleIdentifier: string; + osVersion: string; + locale: string; + attachments: PreparedAttachment[]; +}) { + const attachmentMarkup = + input.attachments.length === 0 + ? "

Attachments: none

" + : `

Attachments:

    ${input.attachments + .map( + (attachment) => + `
  • ${escapeHtml(attachment.filename)} (${escapeHtml( + attachment.contentType, + )}, ${attachment.size} bytes)
  • `, + ) + .join("")}
`; + + return ` +
+

cmux feedback

+

From: ${escapeHtml(input.email)}

+

App version: ${escapeHtml(input.appVersion || "unknown")}

+

App build: ${escapeHtml(input.appBuild || "unknown")}

+

App commit: ${escapeHtml(input.appCommit || "unknown")}

+

Bundle identifier: ${escapeHtml( + input.bundleIdentifier || "unknown", + )}

+

macOS: ${escapeHtml(input.osVersion || "unknown")}

+

Locale: ${escapeHtml(input.locale || "unknown")}

+ ${attachmentMarkup} +

Message

+
${escapeHtml(
+        input.message,
+      )}
+
+ `.trim(); +} + +function sanitizeFilename(fileName: string) { + const cleaned = fileName.replace(/[\r\n"]/g, "").trim(); + return cleaned.length > 0 ? cleaned : "attachment"; +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function jsonError(message: string, status: number) { + return NextResponse.json( + { error: message }, + { + status, + headers: { + "Cache-Control": "no-store", + }, + }, + ); +} diff --git a/web/app/blog/cmd-shift-u/page.tsx b/web/app/blog/cmd-shift-u/page.tsx new file mode 100644 index 00000000..7031b910 --- /dev/null +++ b/web/app/blog/cmd-shift-u/page.tsx @@ -0,0 +1,82 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Cmd+Shift+U", + description: + "How Cmd+Shift+U navigates between finished agents across workspaces in cmux.", + keywords: [ + "cmux", + "terminal", + "macOS", + "notifications", + "AI coding agents", + "keyboard shortcuts", + "developer tools", + "workflow", + ], + openGraph: { + title: "Cmd+Shift+U", + description: + "How Cmd+Shift+U navigates between finished agents across workspaces in cmux.", + type: "article", + publishedTime: "2026-03-04T00:00:00Z", + url: "https://cmux.dev/blog/cmd-shift-u", + }, + twitter: { + card: "summary", + title: "Cmd+Shift+U", + description: + "How Cmd+Shift+U navigates between finished agents across workspaces in cmux.", + }, + alternates: { + canonical: "https://cmux.dev/blog/cmd-shift-u", + }, +}; + +export default function CmdShiftUPage() { + return ( + <> +
+ + ← Back to blog + +
+ +

Cmd+Shift+U

+ + +

+ My favorite cmux feature is Cmd+Shift+U. I have 17 + workspaces open right now, each running an agent. I used to click + through tabs and the notification panel to figure out what completed. + Typing is faster. +

+ +