Merge branch 'main' into issue-151-ssh-remote-port-proxying
# Conflicts: # CLI/cmux.swift # Sources/ContentView.swift # Sources/GhosttyTerminalView.swift # Sources/Panels/BrowserPanel.swift # Sources/Panels/BrowserPanelView.swift # Sources/TabManager.swift # Sources/TerminalController.swift # Sources/Workspace.swift # Sources/WorkspaceContentView.swift # ghostty
12
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
33
.github/pull_request_template.md
vendored
Normal file
|
|
@ -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
|
||||
9
.github/workflows/build-ghosttykit.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
171
.github/workflows/ci-macos-compat.yml
vendored
Normal file
|
|
@ -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
|
||||
198
.github/workflows/ci.yml
vendored
|
|
@ -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_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"
|
||||
else
|
||||
echo "No Xcode.app found under /Applications" >&2
|
||||
if [ ! -d "$XCODE_DIR" ]; then
|
||||
echo "No Xcode found under /Applications" >&2
|
||||
exit 1
|
||||
fi
|
||||
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"
|
||||
# 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" \
|
||||
-only-testing:cmuxUITests/UpdatePillUITests test
|
||||
-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
|
||||
|
|
|
|||
50
.github/workflows/claude.yml
vendored
Normal file
|
|
@ -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:*)'
|
||||
|
||||
285
.github/workflows/nightly.yml
vendored
|
|
@ -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,15 +39,23 @@ 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';
|
||||
|
||||
let headSha = context.sha;
|
||||
if (isMainRef) {
|
||||
const branch = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
});
|
||||
const headSha = branch.data.commit.sha;
|
||||
headSha = branch.data.commit.sha;
|
||||
}
|
||||
|
||||
let nightlySha = null;
|
||||
if (isMainRef) {
|
||||
try {
|
||||
const ref = await github.rest.git.getRef({
|
||||
owner,
|
||||
|
|
@ -63,30 +75,31 @@ jobs:
|
|||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldBuild = forceBuild || nightlySha !== headSha;
|
||||
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"
|
||||
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)"
|
||||
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
|
||||
|
||||
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
|
||||
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"
|
||||
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" \
|
||||
./
|
||||
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)"
|
||||
"$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 with status: $DMG_STATUS" >&2
|
||||
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"
|
||||
xcrun stapler staple "$dmg_release"
|
||||
xcrun stapler validate "$dmg_release"
|
||||
cp "$dmg_release" "$dmg_immutable"
|
||||
}
|
||||
|
||||
# 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"
|
||||
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."
|
||||
|
|
|
|||
42
.github/workflows/release.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
187
.github/workflows/test-depot.yml
vendored
Normal file
|
|
@ -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
|
||||
363
.github/workflows/test-e2e.yml
vendored
Normal file
|
|
@ -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 </dev/null >/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<<EOFSUM"
|
||||
echo "$SUMMARY"
|
||||
echo "EOFSUM"
|
||||
} >> "$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<<EOFOUT"
|
||||
echo "$OUTPUT" | tail -200
|
||||
echo "EOFOUT"
|
||||
} >> "$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
|
||||
|
||||
<details><summary>Test output (last 200 lines)</summary>
|
||||
|
||||
\`\`\`
|
||||
$TEST_OUTPUT
|
||||
\`\`\`
|
||||
|
||||
</details>"
|
||||
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"
|
||||
1
.github/workflows/update-homebrew.yml
vendored
|
|
@ -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",
|
||||
|
|
|
|||
BIN
AppIcon.icon/Assets/cmux-icon-chevron 2.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
35
AppIcon.icon/icon.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 738 B After Width: | Height: | Size: 622 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 24 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/128@2x_dark.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/128_dark.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 610 B After Width: | Height: | Size: 587 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/16@2x_dark.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/16_dark.png
Normal file
|
After Width: | Height: | Size: 591 B |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 92 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/256@2x_dark.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/256_dark.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.1 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/32@2x_dark.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/32_dark.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 404 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/512@2x_dark.png
Normal file
|
After Width: | Height: | Size: 659 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/512_dark.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
|
|
@ -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": [
|
||||
{
|
||||
"filename" : "32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"filename": "16_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"filename": "16@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "16@2x_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"filename": "32.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "32_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"filename": "32@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "32@2x_dark.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"filename": "128.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
Assets.xcassets/AppIconDark.imageset/AppIconDark.png
vendored
Normal file
|
After Width: | Height: | Size: 659 KiB |
12
Assets.xcassets/AppIconDark.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIconDark.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Assets.xcassets/AppIconLight.imageset/AppIconLight.png
vendored
Normal file
|
After Width: | Height: | Size: 404 KiB |
12
Assets.xcassets/AppIconLight.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIconLight.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
97
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
|
||||
|
|
|
|||
68
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 <tag-name>.app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app)
|
||||
=======================================================
|
||||
```
|
||||
|
||||
**Codex** (plain text format):
|
||||
```
|
||||
=======================================================
|
||||
[<tag-name>: file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app)
|
||||
=======================================================
|
||||
```
|
||||
|
||||
Never use `/tmp/cmux-<tag>/...` 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 <your-branch-slug>
|
||||
```
|
||||
|
||||
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-<your-tag> 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 <submodule> && 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-<tag>.sock`) with `CMUX_SOCKET=/tmp/cmux-debug-<tag>.sock`
|
||||
- **Never `open` an untagged `cmux DEV.app`** from DerivedData. It conflicts with the user's running debug instance.
|
||||
|
||||
## Ghostty submodule workflow
|
||||
|
||||
|
|
|
|||
872
CLI/cmux.swift
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
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` 파일을 확인해주세요.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
362
Resources/InfoPlist.xcstrings
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72724
Resources/Localizable.xcstrings
Normal file
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# Non-URL, non-flag argument (file path, etc.) → pass through all
|
||||
passthrough=true
|
||||
break
|
||||
passthrough_args+=("$arg")
|
||||
fi
|
||||
elif has_uri_scheme "$arg"; then
|
||||
passthrough_args+=("$arg")
|
||||
elif is_html_extension "$arg"; then
|
||||
if is_explicit_local_path "$arg" || [[ -e "$arg" ]]; then
|
||||
if local_file_url="$(path_to_file_url "$arg")"; then
|
||||
cmux_targets+=("$local_file_url")
|
||||
else
|
||||
passthrough_args+=("$arg")
|
||||
fi
|
||||
else
|
||||
passthrough_args+=("$arg")
|
||||
fi
|
||||
else
|
||||
passthrough_args+=("$arg")
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$passthrough" == true ]] || [[ ${#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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
[[ -n "$head_path" && -r "$head_path" ]] || return 1
|
||||
local line=""
|
||||
if IFS= read -r line < "$head_path"; then
|
||||
print -r -- "$line"
|
||||
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"
|
||||
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
|
||||
|
|
|
|||
207
Sources/Find/BrowserFindJavaScript.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
193
Sources/Find/BrowserSearchOverlay.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,67 @@
|
|||
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)
|
||||
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)
|
||||
.focused($isSearchFieldFocused)
|
||||
.overlay(alignment: .trailing) {
|
||||
if let selected = searchState.selected {
|
||||
let totalText = searchState.total.map { String($0) } ?? "?"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
)
|
||||
|
|
|
|||
182
Sources/Panels/MarkdownPanel.swift
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
355
Sources/Panels/MarkdownPanelView.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import Combine
|
|||
public enum PanelType: String, Codable, Sendable {
|
||||
case terminal
|
||||
case browser
|
||||
case markdown
|
||||
}
|
||||
|
||||
enum FocusFlashCurve: Equatable {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
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.trackDailyActive(reason: "activeTimer")
|
||||
self.trackHourlyActive(reason: "activeTimer")
|
||||
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.
|
||||
if flush && Self.shouldFlushAfterCapture(event: event) {
|
||||
// For active metrics we care more about delivery than batching.
|
||||
PostHogSDK.shared.flush()
|
||||
}
|
||||
|
||||
func trackHourlyActive(reason: String) {
|
||||
startIfNeeded()
|
||||
guard didStart else { return }
|
||||
return true
|
||||
}
|
||||
|
||||
@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 ?? [:]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func flush() {
|
||||
guard didStart else { return }
|
||||
if flush && Self.shouldFlushAfterCapture(event: event) {
|
||||
// Keep hourly freshness and avoid losing a deduped hour on abrupt exits.
|
||||
PostHogSDK.shared.flush()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func dispatchAsyncOnWorkQueue(_ block: @escaping () -> Void) {
|
||||
if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil {
|
||||
block()
|
||||
return
|
||||
}
|
||||
workQueue.async(execute: block)
|
||||
}
|
||||
|
||||
private func dispatchSyncOnWorkQueue(_ block: () -> Void) {
|
||||
if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil {
|
||||
block()
|
||||
return
|
||||
}
|
||||
workQueue.sync(execute: block)
|
||||
}
|
||||
|
||||
private func utcHourString(_ date: Date) -> String {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,12 +1278,15 @@ 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)
|
||||
|
||||
if selectedTabId == workspace.id {
|
||||
|
|
@ -1038,13 +1297,13 @@ class TabManager: ObservableObject {
|
|||
selectedTabId = tabs[newIndex].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detach a workspace from this window without closing its panels.
|
||||
/// Used by the socket API for cross-window moves.
|
||||
@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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,36 +8047,56 @@ 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)
|
||||
#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)
|
||||
}
|
||||
if !staleMappings.isEmpty {
|
||||
|
||||
let closedSet = Set(closedPanelIds)
|
||||
surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) }
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
||||
refreshFocusedGitBranchState()
|
||||
scheduleTerminalGeometryReconcile()
|
||||
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 {
|
||||
// Check if any panel in this pane needs close confirmation
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ==="
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
|
|
|
|||