Merge origin/main into feat-cmux-themes-command
This commit is contained in:
commit
cd04bb8932
53 changed files with 3425 additions and 336 deletions
127
.github/workflows/nightly.yml
vendored
127
.github/workflows/nightly.yml
vendored
|
|
@ -13,10 +13,9 @@ on:
|
|||
|
||||
concurrency:
|
||||
group: nightly-build-${{ github.ref_name }}
|
||||
# Queue main pushes instead of hard-canceling older runs. The decide job
|
||||
# already coalesces to the current main HEAD, and we re-check HEAD before
|
||||
# publishing so stale queued runs exit cleanly instead of showing up red.
|
||||
cancel-in-progress: false
|
||||
# Only the newest nightly matters. Cancel older runs so a fresh main push
|
||||
# does not sit behind an outdated build that would be discarded anyway.
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -100,7 +99,7 @@ jobs:
|
|||
build-sign-notarize-nightly:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.should_build == 'true'
|
||||
runs-on: macos-15
|
||||
runs-on: depot-macos-latest
|
||||
steps:
|
||||
- name: Checkout build ref
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
|
@ -108,7 +107,29 @@ jobs:
|
|||
ref: ${{ needs.decide.outputs.head_sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Check whether build commit is still current main HEAD before build
|
||||
if: needs.decide.outputs.should_publish == 'true'
|
||||
id: current_head_prebuild
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
|
||||
BUILD_SHA="${{ needs.decide.outputs.head_sha }}"
|
||||
if [ "$CURRENT_MAIN_SHA" = "$BUILD_SHA" ]; then
|
||||
STILL_CURRENT=true
|
||||
else
|
||||
STILL_CURRENT=false
|
||||
fi
|
||||
echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### Pre-build publish guard"
|
||||
echo
|
||||
echo "- build sha: \`$BUILD_SHA\`"
|
||||
echo "- current main sha: \`$CURRENT_MAIN_SHA\`"
|
||||
echo "- continue build/sign/publish: \`$STILL_CURRENT\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Select Xcode
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
|
||||
|
|
@ -128,14 +149,17 @@ jobs:
|
|||
xcrun --sdk macosx --show-sdk-path
|
||||
|
||||
- name: Install build deps
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
run: |
|
||||
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
|
||||
|
||||
- name: Download pre-built GhosttyKit.xcframework
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
run: |
|
||||
./scripts/download-prebuilt-ghosttykit.sh
|
||||
|
||||
- name: Cache Swift packages
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .spm-cache
|
||||
|
|
@ -143,6 +167,7 @@ jobs:
|
|||
restore-keys: spm-
|
||||
|
||||
- name: Derive Sparkle public key from private key
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
env:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
run: |
|
||||
|
|
@ -154,16 +179,8 @@ jobs:
|
|||
echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY"
|
||||
echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Apple Silicon app (Release)
|
||||
run: |
|
||||
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: Build universal app (Release)
|
||||
- name: Build universal nightly app (Release)
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
run: |
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \
|
||||
-destination 'generic/platform=macOS' \
|
||||
|
|
@ -173,35 +190,29 @@ jobs:
|
|||
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
|
||||
|
||||
- name: Verify nightly binary architectures
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARM_APP_BINARY="build-arm/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
|
||||
ARM_CLI_BINARY="build-arm/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
|
||||
APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
|
||||
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
|
||||
ARM_APP_ARCHS="$(lipo -archs "$ARM_APP_BINARY")"
|
||||
ARM_CLI_ARCHS="$(lipo -archs "$ARM_CLI_BINARY")"
|
||||
APP_ARCHS="$(lipo -archs "$APP_BINARY")"
|
||||
CLI_ARCHS="$(lipo -archs "$CLI_BINARY")"
|
||||
echo "Arm app binary architectures: $ARM_APP_ARCHS"
|
||||
echo "Arm CLI binary architectures: $ARM_CLI_ARCHS"
|
||||
echo "App binary architectures: $APP_ARCHS"
|
||||
echo "CLI binary architectures: $CLI_ARCHS"
|
||||
[[ "$ARM_APP_ARCHS" == "arm64" ]]
|
||||
[[ "$ARM_CLI_ARCHS" == "arm64" ]]
|
||||
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
|
||||
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
|
||||
|
||||
- name: Run CLI version memory guard regression
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
|
||||
[ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; }
|
||||
CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py
|
||||
|
||||
- name: Check whether build commit is still current main HEAD
|
||||
if: needs.decide.outputs.should_publish == 'true'
|
||||
id: current_head
|
||||
- name: Check whether build commit is still current main HEAD after build
|
||||
if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
id: current_head_postbuild
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
|
||||
|
|
@ -213,7 +224,7 @@ jobs:
|
|||
fi
|
||||
echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### Publish guard"
|
||||
echo "### Post-build publish guard"
|
||||
echo
|
||||
echo "- build sha: \`$BUILD_SHA\`"
|
||||
echo "- current main sha: \`$CURRENT_MAIN_SHA\`"
|
||||
|
|
@ -221,14 +232,13 @@ jobs:
|
|||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Inject nightly identities and metadata
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHORT_SHA="${{ needs.decide.outputs.short_sha }}"
|
||||
ARM_APP_DIR="build-arm/Build/Products/Release"
|
||||
UNIVERSAL_APP_DIR="build-universal/Build/Products/Release"
|
||||
APP_DIR="build-universal/Build/Products/Release"
|
||||
|
||||
BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${ARM_APP_DIR}/cmux.app/Contents/Info.plist")
|
||||
BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${APP_DIR}/cmux.app/Contents/Info.plist")
|
||||
NIGHTLY_DATE=$(date -u +%Y%m%d)
|
||||
|
||||
# Build number: unique/monotonic per workflow run attempt so same-day
|
||||
|
|
@ -241,10 +251,8 @@ jobs:
|
|||
fi
|
||||
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
|
||||
|
||||
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"
|
||||
NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
|
||||
echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
|
||||
|
||||
prepare_variant() {
|
||||
local app_dir="$1"
|
||||
|
|
@ -267,25 +275,19 @@ jobs:
|
|||
}
|
||||
|
||||
prepare_variant \
|
||||
"$ARM_APP_DIR" \
|
||||
"$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 arm64 bundle ID: com.cmuxterm.app.nightly"
|
||||
echo "Nightly universal bundle ID: com.cmuxterm.app.nightly.universal"
|
||||
echo "Nightly bundle ID: com.cmuxterm.app.nightly"
|
||||
echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}"
|
||||
echo "Nightly build number: ${NIGHTLY_BUILD}"
|
||||
echo "Nightly arm64 immutable DMG: ${ARM_DMG_IMMUTABLE}"
|
||||
echo "Nightly universal immutable DMG: ${UNIVERSAL_DMG_IMMUTABLE}"
|
||||
echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}"
|
||||
echo "Commit SHA: ${SHORT_SHA}"
|
||||
|
||||
- name: Import signing cert
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
|
||||
env:
|
||||
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
|
@ -309,7 +311,7 @@ jobs:
|
|||
security list-keychains -d user -s build.keychain
|
||||
|
||||
- name: Codesign apps
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
|
|
@ -319,7 +321,6 @@ jobs:
|
|||
fi
|
||||
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"
|
||||
|
|
@ -331,7 +332,7 @@ jobs:
|
|||
done
|
||||
|
||||
- name: Notarize apps and dmgs
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
|
|
@ -391,16 +392,12 @@ jobs:
|
|||
}
|
||||
|
||||
notarize_and_package \
|
||||
"build-arm/Build/Products/Release/cmux NIGHTLY.app" \
|
||||
"build-universal/Build/Products/Release/cmux NIGHTLY.app" \
|
||||
"cmux-nightly-macos.dmg" \
|
||||
"$NIGHTLY_DMG_IMMUTABLE"
|
||||
notarize_and_package \
|
||||
"build-universal/Build/Products/Release/cmux NIGHTLY.app" \
|
||||
"cmux-nightly-universal-macos.dmg" \
|
||||
"$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE"
|
||||
|
||||
- name: Upload dSYMs to Sentry
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: manaflow
|
||||
|
|
@ -412,11 +409,10 @@ jobs:
|
|||
fi
|
||||
brew install getsentry/tools/sentry-cli || true
|
||||
sentry-cli debug-files upload --include-sources \
|
||||
build-arm/Build/Products/Release/ \
|
||||
build-universal/Build/Products/Release/
|
||||
|
||||
- name: Generate Sparkle appcasts (nightly)
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
|
||||
env:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
run: |
|
||||
|
|
@ -425,7 +421,9 @@ 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
|
||||
# Keep the legacy universal feed alive long enough for older nightly
|
||||
# installs to migrate onto the unified nightly appcast.
|
||||
cp appcast.xml appcast-universal.xml
|
||||
|
||||
- name: Upload branch nightly artifacts
|
||||
if: needs.decide.outputs.should_publish != 'true'
|
||||
|
|
@ -434,13 +432,12 @@ jobs:
|
|||
name: cmux-nightly-${{ needs.decide.outputs.short_sha }}
|
||||
path: |
|
||||
cmux-nightly-macos*.dmg
|
||||
cmux-nightly-universal-macos*.dmg
|
||||
appcast.xml
|
||||
appcast-universal.xml
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Move nightly tag to built commit
|
||||
if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true'
|
||||
if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
|
|
@ -449,7 +446,7 @@ jobs:
|
|||
git push origin refs/tags/nightly --force
|
||||
|
||||
- name: Publish nightly release assets
|
||||
if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true'
|
||||
if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true'
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
tag_name: nightly
|
||||
|
|
@ -459,17 +456,15 @@ jobs:
|
|||
body: |
|
||||
Automated nightly build for `${{ needs.decide.outputs.short_sha }}`.
|
||||
|
||||
**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`
|
||||
**cmux NIGHTLY** is published as a universal app:
|
||||
- bundle ID `com.cmuxterm.app.nightly`
|
||||
- feed `appcast.xml`
|
||||
- compatibility feed `appcast-universal.xml` for older universal nightlies
|
||||
|
||||
[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
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@
|
|||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
|
||||
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
|
||||
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.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 */; };
|
||||
|
|
@ -238,6 +239,7 @@
|
|||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
|
||||
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
|
||||
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
|
||||
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
|
||||
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
|
|
@ -472,6 +474,7 @@
|
|||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
|
||||
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
|
||||
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
|
||||
A5008380 /* BrowserFindJavaScriptTests.swift */,
|
||||
A5008382 /* CommandPaletteSearchEngineTests.swift */,
|
||||
);
|
||||
|
|
@ -711,6 +714,7 @@
|
|||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
|
||||
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */,
|
||||
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
|
||||
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
|
||||
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -213,6 +213,8 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in
|
|||
|
||||
cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed.
|
||||
|
||||
Report nightly bugs on [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) or in [#nightly-bugs on Discord](https://discord.gg/xsgFEVrWCZ).
|
||||
|
||||
## Session restore (current behavior)
|
||||
|
||||
On relaunch, cmux currently restores app layout and metadata only:
|
||||
|
|
|
|||
|
|
@ -93,15 +93,27 @@
|
|||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.splittabbar.tabtransfer</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Bonsplit Tab Transfer</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.cmux.sidebar-tab-reorder</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>cmux Sidebar Tab Reorder</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
|
|
|||
|
|
@ -845,13 +845,13 @@
|
|||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Welcome"
|
||||
"value": "Welcome to cmux!"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ようこそ"
|
||||
"value": "cmuxへようこそ!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -873,6 +873,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.discord": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Discord"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Discord"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.githubIssues": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -27859,6 +27876,57 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"dialog.closeWorkspaces.message": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "This will close %1$lld workspaces and all of their panels:\n%2$@"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%1$lld 個のワークスペースと、それぞれのすべてのパネルを閉じます:\n%2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialog.closeWorkspaces.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Close workspaces?"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ワークスペースを閉じますか?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialog.closeWorkspacesWindow.message": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "現在のウィンドウと、その %1$lld 個のワークスペースと、それぞれのすべてのパネルを閉じます:\n%2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialog.closeWorkspace.message": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -34978,6 +35046,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"menu.openInVSCodeDesktop": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Open Current Directory in VS Code"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "現在のディレクトリを VS Code で開く"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"menu.openInWarp": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -40193,6 +40278,119 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"settings.app.appIcon.subtitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock and app switcher"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dockとアプリスイッチャー"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "程序坞和应用切换器"
|
||||
}
|
||||
},
|
||||
"zh-Hant": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock 和 App 切換器"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock 및 앱 전환기"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock und App-Umschalter"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock y selector de apps"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock et sélecteur d'apps"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock e selettore app"
|
||||
}
|
||||
},
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock og appskifter"
|
||||
}
|
||||
},
|
||||
"pl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock i przełącznik aplikacji"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock и переключатель приложений"
|
||||
}
|
||||
},
|
||||
"bs": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock i prebacivač aplikacija"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "شريط Dock ومبدّل التطبيقات"
|
||||
}
|
||||
},
|
||||
"nb": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock og appbytter"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock e alternador de apps"
|
||||
}
|
||||
},
|
||||
"th": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock และตัวสลับแอป"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock ve uygulama değiştirici"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.dockBadge": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -40419,6 +40617,40 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"settings.app.showInMenuBar": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Show in Menu Bar"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "メニューバーに表示"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.showInMenuBar.subtitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Keep cmux in the menu bar for unread notifications and quick actions."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "未読通知の確認やクイック操作のために、cmuxをメニューバーに表示します。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.language": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -41097,6 +41329,57 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"settings.app.closeWorkspaceOnLastSurfaceShortcut": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Closing Last Surface Closes Workspace"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "最後のサーフェスを閉じるとワークスペースも閉じる"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Closing the last surface keeps the workspace open. Use Cmd+Shift+W to close a workspace explicitly."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "最後のサーフェスを閉じてもワークスペースは残ります。ワークスペースを明示的に閉じるにはCmd+Shift+Wを使います。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Closing the last surface also closes its workspace."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "最後のサーフェスを閉じると、そのワークスペースも閉じます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.openSidebarPRLinks": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -45019,6 +45302,74 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"settings.notifications.paneRing.subtitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Show a blue ring around panes with unread notifications."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "未読の通知があるペインの周囲に青いリングを表示します。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.notifications.paneFlash.subtitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Briefly flash a blue outline when cmux highlights a pane."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "cmux がペインを強調表示するときに短い青いアウトラインを表示します。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.notifications.paneFlash.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Pane Flash"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ペインフラッシュ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.notifications.paneRing.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Unread Pane Ring"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "未読ペインリング"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.notifications.sound.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ _CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}"
|
|||
_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
|
||||
|
||||
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
|
||||
_CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}"
|
||||
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
|
||||
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
|
||||
|
||||
|
|
@ -103,6 +104,19 @@ _cmux_report_tty_once() {
|
|||
} >/dev/null 2>&1 & disown
|
||||
}
|
||||
|
||||
_cmux_report_shell_activity_state() {
|
||||
local state="$1"
|
||||
[[ -n "$state" ]] || return 0
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
|
||||
_CMUX_SHELL_ACTIVITY_LAST="$state"
|
||||
{
|
||||
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 & disown
|
||||
}
|
||||
|
||||
_cmux_ports_kick() {
|
||||
# Lightweight: just tell the app to run a batched scan for this panel.
|
||||
# The app coalesces kicks across all panels and runs a single ps+lsof.
|
||||
|
|
@ -291,10 +305,33 @@ _cmux_bash_cleanup() {
|
|||
_cmux_stop_pr_poll_loop
|
||||
}
|
||||
|
||||
_cmux_preexec_command() {
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
t="$(tty 2>/dev/null || true)"
|
||||
t="${t##*/}"
|
||||
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||
fi
|
||||
|
||||
_cmux_report_shell_activity_state running
|
||||
_cmux_report_tty_once
|
||||
_cmux_ports_kick
|
||||
_cmux_stop_pr_poll_loop
|
||||
}
|
||||
|
||||
_cmux_bash_preexec_hook() {
|
||||
_cmux_preexec_command
|
||||
}
|
||||
|
||||
_cmux_prompt_command() {
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
_cmux_report_shell_activity_state prompt
|
||||
|
||||
local now=$SECONDS
|
||||
local pwd="$PWD"
|
||||
|
|
@ -439,6 +476,17 @@ _cmux_install_prompt_command() {
|
|||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
|
||||
builtin readonly _CMUX_BASH_PS0='${ _cmux_bash_preexec_hook; }'
|
||||
else
|
||||
builtin readonly _CMUX_BASH_PS0='$(_cmux_bash_preexec_hook >/dev/null)'
|
||||
fi
|
||||
if [[ "$PS0" != *"${_CMUX_BASH_PS0}"* ]]; then
|
||||
PS0=$PS0"${_CMUX_BASH_PS0}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure Resources/bin is at the front of PATH, and remove the app's
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
|
|||
|
||||
typeset -g _CMUX_PORTS_LAST_RUN=0
|
||||
typeset -g _CMUX_CMD_START=0
|
||||
typeset -g _CMUX_SHELL_ACTIVITY_LAST=""
|
||||
typeset -g _CMUX_TTY_NAME=""
|
||||
typeset -g _CMUX_TTY_REPORTED=0
|
||||
|
||||
|
|
@ -110,6 +111,19 @@ _cmux_report_tty_once() {
|
|||
} >/dev/null 2>&1 &!
|
||||
}
|
||||
|
||||
_cmux_report_shell_activity_state() {
|
||||
local state="$1"
|
||||
[[ -n "$state" ]] || return 0
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
|
||||
_CMUX_SHELL_ACTIVITY_LAST="$state"
|
||||
{
|
||||
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 &!
|
||||
}
|
||||
|
||||
_cmux_ports_kick() {
|
||||
# Lightweight: just tell the app to run a batched scan for this panel.
|
||||
# The app coalesces kicks across all panels and runs a single ps+lsof.
|
||||
|
|
@ -361,6 +375,7 @@ _cmux_preexec() {
|
|||
fi
|
||||
|
||||
_CMUX_CMD_START=$EPOCHSECONDS
|
||||
_cmux_report_shell_activity_state running
|
||||
|
||||
# Heuristic: commands that may change git branch/dirty state without changing $PWD.
|
||||
local cmd="${1## }"
|
||||
|
|
@ -384,6 +399,7 @@ _cmux_precmd() {
|
|||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
_cmux_report_shell_activity_state prompt
|
||||
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
|
|
|
|||
|
|
@ -396,6 +396,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
case terminal
|
||||
case tower
|
||||
case vscode
|
||||
case vscodeInline
|
||||
case warp
|
||||
case windsurf
|
||||
case xcode
|
||||
|
|
@ -446,6 +447,8 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
case .tower:
|
||||
return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower")
|
||||
case .vscode:
|
||||
return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code")
|
||||
case .vscodeInline:
|
||||
return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)")
|
||||
case .warp:
|
||||
return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp")
|
||||
|
|
@ -478,6 +481,8 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
case .tower:
|
||||
return common + ["tower", "git", "client"]
|
||||
case .vscode:
|
||||
return common + ["vs", "code", "visual", "studio", "desktop", "app"]
|
||||
case .vscodeInline:
|
||||
return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"]
|
||||
case .warp:
|
||||
return common + ["warp", "terminal", "shell"]
|
||||
|
|
@ -492,7 +497,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
|
||||
func isAvailable(in environment: DetectionEnvironment = .live) -> Bool {
|
||||
guard let applicationPath = applicationPath(in: environment) else { return false }
|
||||
guard self == .vscode else { return true }
|
||||
guard self == .vscodeInline else { return true }
|
||||
return VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
|
||||
vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true),
|
||||
isExecutableAtPath: environment.isExecutableFileAtPath
|
||||
|
|
@ -557,6 +562,11 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
"/Applications/Visual Studio Code.app",
|
||||
"/Applications/Code.app",
|
||||
]
|
||||
case .vscodeInline:
|
||||
return [
|
||||
"/Applications/Visual Studio Code.app",
|
||||
"/Applications/Code.app",
|
||||
]
|
||||
case .warp:
|
||||
return ["/Applications/Warp.app"]
|
||||
case .windsurf:
|
||||
|
|
@ -1910,6 +1920,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private var windowKeyObserver: NSObjectProtocol?
|
||||
private var shortcutMonitor: Any?
|
||||
private var shortcutDefaultsObserver: NSObjectProtocol?
|
||||
private var menuBarVisibilityObserver: NSObjectProtocol?
|
||||
private var splitButtonTooltipRefreshScheduled = false
|
||||
private var ghosttyConfigObserver: NSObjectProtocol?
|
||||
private var ghosttyGotoSplitLeftShortcut: StoredShortcut?
|
||||
|
|
@ -2208,7 +2219,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
ensureApplicationIcon()
|
||||
if !isRunningUnderXCTest {
|
||||
configureUserNotifications()
|
||||
setupMenuBarExtra()
|
||||
installMenuBarVisibilityObserver()
|
||||
syncMenuBarExtraVisibility()
|
||||
// Sparkle updater is started lazily on first manual check. This avoids any
|
||||
// first-launch permission prompts and keeps cmux aligned with the update pill UI.
|
||||
}
|
||||
|
|
@ -4588,6 +4600,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier })
|
||||
}
|
||||
|
||||
private func resolvedWindow(for context: MainWindowContext) -> NSWindow? {
|
||||
guard let window = context.window ?? windowForMainWindowId(context.windowId) else {
|
||||
return nil
|
||||
}
|
||||
context.window = window
|
||||
return window
|
||||
}
|
||||
|
||||
private func mainWindowId(from window: NSWindow) -> UUID? {
|
||||
guard let raw = window.identifier?.rawValue else { return nil }
|
||||
let prefix = "cmux.main."
|
||||
|
|
@ -4665,6 +4685,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return removed
|
||||
}
|
||||
|
||||
private func discardOrphanedMainWindowContext(_ context: MainWindowContext) {
|
||||
let contextKeys = mainWindowContexts.compactMap { key, value in
|
||||
value === context ? key : nil
|
||||
}
|
||||
for key in contextKeys {
|
||||
mainWindowContexts.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPaletteEscapeSuppressionByWindowId.remove(context.windowId)
|
||||
commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId)
|
||||
|
||||
if tabManager === context.tabManager {
|
||||
if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) {
|
||||
tabManager = nextContext.tabManager
|
||||
sidebarState = nextContext.sidebarState
|
||||
sidebarSelectionState = nextContext.sidebarSelectionState
|
||||
TerminalController.shared.setActiveTabManager(nextContext.tabManager)
|
||||
} else {
|
||||
tabManager = nil
|
||||
sidebarState = nil
|
||||
sidebarSelectionState = nil
|
||||
TerminalController.shared.setActiveTabManager(nil)
|
||||
}
|
||||
}
|
||||
|
||||
if let store = notificationStore {
|
||||
for tab in context.tabManager.tabs {
|
||||
store.clearNotifications(forTabId: tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mainWindowId(for window: NSWindow) -> UUID? {
|
||||
if let context = mainWindowContexts[ObjectIdentifier(window)] {
|
||||
return context.windowId
|
||||
|
|
@ -5090,11 +5147,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
#endif
|
||||
return nil
|
||||
}
|
||||
if let window = context.window ?? windowForMainWindowId(context.windowId) {
|
||||
setActiveMainWindow(window)
|
||||
if shouldBringToFront {
|
||||
bringToFront(window)
|
||||
}
|
||||
guard let window = resolvedWindow(for: context) else {
|
||||
#if DEBUG
|
||||
logWorkspaceCreationRouting(
|
||||
phase: "no_context",
|
||||
source: debugSource,
|
||||
reason: "context_window_missing",
|
||||
event: event,
|
||||
chosenContext: context,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
#endif
|
||||
discardOrphanedMainWindowContext(context)
|
||||
return nil
|
||||
}
|
||||
setActiveMainWindow(window)
|
||||
if shouldBringToFront {
|
||||
bringToFront(window)
|
||||
}
|
||||
|
||||
let workspace: Workspace
|
||||
|
|
@ -5183,7 +5252,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
}
|
||||
|
||||
let fallback = mainWindowContexts.values.first
|
||||
let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil })
|
||||
#if DEBUG
|
||||
logWorkspaceCreationRouting(
|
||||
phase: "choose",
|
||||
|
|
@ -5547,6 +5616,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
private func setupMenuBarExtra() {
|
||||
guard menuBarExtraController == nil else { return }
|
||||
let store = TerminalNotificationStore.shared
|
||||
menuBarExtraController = MenuBarExtraController(
|
||||
notificationStore: store,
|
||||
|
|
@ -5575,6 +5645,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
}
|
||||
|
||||
private func installMenuBarVisibilityObserver() {
|
||||
guard menuBarVisibilityObserver == nil else { return }
|
||||
menuBarVisibilityObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.syncMenuBarExtraVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncMenuBarExtraVisibility(defaults: UserDefaults = .standard) {
|
||||
if MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults) {
|
||||
setupMenuBarExtra()
|
||||
return
|
||||
}
|
||||
|
||||
menuBarExtraController?.removeFromMenuBar()
|
||||
menuBarExtraController = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func presentPreferencesWindow(
|
||||
navigationTarget: SettingsNavigationTarget? = nil,
|
||||
|
|
@ -7753,6 +7846,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
// equivalents working and avoid surprising actions while the confirmation is up.
|
||||
let closeConfirmationTitles = [
|
||||
String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"),
|
||||
String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?"),
|
||||
String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
|
||||
String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"),
|
||||
String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"),
|
||||
|
|
@ -10198,6 +10292,13 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate {
|
|||
refreshUI()
|
||||
}
|
||||
|
||||
func removeFromMenuBar() {
|
||||
notificationsCancellable?.cancel()
|
||||
notificationsCancellable = nil
|
||||
statusItem.menu = nil
|
||||
NSStatusBar.system.removeStatusItem(statusItem)
|
||||
}
|
||||
|
||||
private func refreshUI() {
|
||||
let snapshot = NotificationMenuSnapshotBuilder.make(
|
||||
notifications: notificationStore.notifications,
|
||||
|
|
@ -10520,6 +10621,18 @@ enum MenuBarBuildHintFormatter {
|
|||
}
|
||||
}
|
||||
|
||||
enum MenuBarExtraSettings {
|
||||
static let showInMenuBarKey = "showMenuBarExtra"
|
||||
static let defaultShowInMenuBar = true
|
||||
|
||||
static func showsMenuBarExtra(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: showInMenuBarKey) == nil {
|
||||
return defaultShowInMenuBar
|
||||
}
|
||||
return defaults.bool(forKey: showInMenuBarKey)
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuBarBadgeRenderConfig {
|
||||
var badgeRect: NSRect
|
||||
var singleDigitFontSize: CGFloat
|
||||
|
|
|
|||
|
|
@ -1330,6 +1330,8 @@ struct ContentView: View {
|
|||
@State private var retiringWorkspaceId: UUID?
|
||||
@State private var workspaceHandoffGeneration: UInt64 = 0
|
||||
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
|
||||
@State private var didApplyUITestSidebarSelection = false
|
||||
@State private var workspaceHandoffReadyCheckTask: Task<Void, Never>?
|
||||
@State private var titlebarThemeGeneration: UInt64 = 0
|
||||
@State private var sidebarDraggedTabId: UUID?
|
||||
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
|
||||
|
|
@ -2233,6 +2235,8 @@ struct ContentView: View {
|
|||
selectedTabIds = [selectedId]
|
||||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||||
}
|
||||
syncSidebarSelectedWorkspaceIds()
|
||||
applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs)
|
||||
updateTitlebarText()
|
||||
|
||||
// Startup recovery (#399): if session restore or a race condition leaves the
|
||||
|
|
@ -2267,6 +2271,9 @@ struct ContentView: View {
|
|||
didRecover = true
|
||||
}
|
||||
|
||||
syncSidebarSelectedWorkspaceIds()
|
||||
applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs)
|
||||
|
||||
if didRecover {
|
||||
#if DEBUG
|
||||
dlog("startup.recovery tabCount=\(tabManager.tabs.count) selected=\(tabManager.selectedTabId?.uuidString.prefix(8) ?? "nil") mounted=\(mountedWorkspaceIds.count)")
|
||||
|
|
@ -2302,6 +2309,10 @@ struct ContentView: View {
|
|||
updateTitlebarText()
|
||||
})
|
||||
|
||||
view = AnyView(view.onChange(of: selectedTabIds) { _ in
|
||||
syncSidebarSelectedWorkspaceIds()
|
||||
})
|
||||
|
||||
view = AnyView(view.onChange(of: tabManager.isWorkspaceCycleHot) { _ in
|
||||
#if DEBUG
|
||||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
|
|
@ -2401,6 +2412,8 @@ struct ContentView: View {
|
|||
lastSidebarSelectionIndex = nil
|
||||
}
|
||||
}
|
||||
syncSidebarSelectedWorkspaceIds()
|
||||
applyUITestSidebarSelectionIfNeeded(tabs: tabs)
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.stateDidChange)) { notification in
|
||||
|
|
@ -2869,6 +2882,8 @@ struct ContentView: View {
|
|||
retiringWorkspaceId = nil
|
||||
workspaceHandoffFallbackTask?.cancel()
|
||||
workspaceHandoffFallbackTask = nil
|
||||
workspaceHandoffReadyCheckTask?.cancel()
|
||||
workspaceHandoffReadyCheckTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2876,6 +2891,7 @@ struct ContentView: View {
|
|||
let generation = workspaceHandoffGeneration
|
||||
retiringWorkspaceId = oldSelectedId
|
||||
workspaceHandoffFallbackTask?.cancel()
|
||||
workspaceHandoffReadyCheckTask?.cancel()
|
||||
|
||||
#if DEBUG
|
||||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
|
|
@ -2891,6 +2907,36 @@ struct ContentView: View {
|
|||
}
|
||||
#endif
|
||||
|
||||
workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in
|
||||
for delay in [0, 20_000_000, 40_000_000, 60_000_000] {
|
||||
if delay > 0 {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(delay))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
let completed = await MainActor.run { () -> Bool in
|
||||
guard workspaceHandoffGeneration == generation else { return false }
|
||||
guard retiringWorkspaceId != nil else { return false }
|
||||
guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false }
|
||||
#if DEBUG
|
||||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||
dlog(
|
||||
"ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))"
|
||||
)
|
||||
} else {
|
||||
dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))")
|
||||
}
|
||||
#endif
|
||||
completeWorkspaceHandoff(reason: "ready")
|
||||
return true
|
||||
}
|
||||
if completed { return }
|
||||
}
|
||||
}
|
||||
|
||||
workspaceHandoffFallbackTask = Task { [generation] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 150_000_000)
|
||||
|
|
@ -2910,9 +2956,20 @@ struct ContentView: View {
|
|||
completeWorkspaceHandoff(reason: reason)
|
||||
}
|
||||
|
||||
private func canCompleteWorkspaceHandoffImmediately(for workspaceId: UUID) -> Bool {
|
||||
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return true }
|
||||
if let focusedPanelId = workspace.focusedPanelId,
|
||||
workspace.browserPanel(for: focusedPanelId) != nil {
|
||||
return true
|
||||
}
|
||||
return workspace.hasLoadedTerminalSurface()
|
||||
}
|
||||
|
||||
private func completeWorkspaceHandoff(reason: String) {
|
||||
workspaceHandoffFallbackTask?.cancel()
|
||||
workspaceHandoffFallbackTask = nil
|
||||
workspaceHandoffReadyCheckTask?.cancel()
|
||||
workspaceHandoffReadyCheckTask = nil
|
||||
let retiring = retiringWorkspaceId
|
||||
|
||||
// Hide portal-hosted views for the retiring workspace BEFORE clearing
|
||||
|
|
@ -4659,7 +4716,7 @@ struct ContentView: View {
|
|||
keywords: ["vscode", "inline", "serve-web", "stop", "server"],
|
||||
when: { context in
|
||||
context.bool(CommandPaletteContextKeys.panelIsTerminal)
|
||||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode))
|
||||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -4671,7 +4728,7 @@ struct ContentView: View {
|
|||
keywords: ["vscode", "inline", "serve-web", "restart", "server"],
|
||||
when: { context in
|
||||
context.bool(CommandPaletteContextKeys.panelIsTerminal)
|
||||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode))
|
||||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -5962,11 +6019,7 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) {
|
||||
for workspaceId in workspaceIds {
|
||||
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { continue }
|
||||
guard allowPinned || !workspace.isPinned else { continue }
|
||||
tabManager.closeWorkspaceWithConfirmation(workspace)
|
||||
}
|
||||
tabManager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned)
|
||||
}
|
||||
|
||||
private func closeOtherSelectedWorkspaces() {
|
||||
|
|
@ -5976,19 +6029,53 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private func closeSelectedWorkspacesBelow() {
|
||||
guard let workspace = tabManager.selectedWorkspace,
|
||||
guard tabManager.selectedWorkspace != nil,
|
||||
let anchorIndex = selectedWorkspaceIndex() else { return }
|
||||
let workspaceIds = tabManager.tabs.suffix(from: anchorIndex + 1).map(\.id)
|
||||
closeWorkspaceIds(workspaceIds, allowPinned: false)
|
||||
}
|
||||
|
||||
private func closeSelectedWorkspacesAbove() {
|
||||
guard let workspace = tabManager.selectedWorkspace,
|
||||
guard tabManager.selectedWorkspace != nil,
|
||||
let anchorIndex = selectedWorkspaceIndex() else { return }
|
||||
let workspaceIds = tabManager.tabs.prefix(upTo: anchorIndex).map(\.id)
|
||||
closeWorkspaceIds(workspaceIds, allowPinned: false)
|
||||
}
|
||||
|
||||
private func syncSidebarSelectedWorkspaceIds() {
|
||||
tabManager.setSidebarSelectedWorkspaceIds(selectedTabIds)
|
||||
}
|
||||
|
||||
private func applyUITestSidebarSelectionIfNeeded(tabs: [Workspace]) {
|
||||
#if DEBUG
|
||||
guard !didApplyUITestSidebarSelection else { return }
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let rawValue = env["CMUX_UI_TEST_SIDEBAR_SELECTED_WORKSPACE_INDICES"]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!rawValue.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
var indices: [Int] = []
|
||||
for token in rawValue.split(separator: ",") {
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let index = Int(trimmed), index >= 0 else { return }
|
||||
if !indices.contains(index) {
|
||||
indices.append(index)
|
||||
}
|
||||
}
|
||||
|
||||
guard let lastIndex = indices.last, !indices.isEmpty, lastIndex < tabs.count else { return }
|
||||
|
||||
let selectedIds = Set(indices.map { tabs[$0].id })
|
||||
selectedTabIds = selectedIds
|
||||
lastSidebarSelectionIndex = lastIndex
|
||||
tabManager.selectWorkspace(tabs[lastIndex])
|
||||
sidebarSelectionState.selection = .tabs
|
||||
didApplyUITestSidebarSelection = true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func beginRenameWorkspaceFlow() {
|
||||
guard let workspace = tabManager.selectedWorkspace else {
|
||||
NSSound.beep()
|
||||
|
|
@ -6102,7 +6189,7 @@ struct ContentView: View {
|
|||
case .finder:
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path)
|
||||
return true
|
||||
case .vscode:
|
||||
case .vscodeInline:
|
||||
return openFocusedDirectoryInInlineVSCode(directoryURL)
|
||||
default:
|
||||
guard let applicationURL = target.applicationURL() else { return false }
|
||||
|
|
@ -6113,7 +6200,7 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private func openFocusedDirectoryInInlineVSCode(_ directoryURL: URL) -> Bool {
|
||||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL(),
|
||||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL(),
|
||||
let workspace = tabManager.selectedWorkspace,
|
||||
let sourcePanelId = workspace.focusedPanelId else {
|
||||
return false
|
||||
|
|
@ -6149,7 +6236,7 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private func restartInlineVSCodeServeWeb() -> Bool {
|
||||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL() else {
|
||||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else {
|
||||
return false
|
||||
}
|
||||
VSCodeServeWebController.shared.restart(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in
|
||||
|
|
@ -7179,6 +7266,9 @@ struct VerticalTabsSidebar: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
let workspaceCount = tabManager.tabs.count
|
||||
let canCloseWorkspace = workspaceCount > 1
|
||||
|
||||
VStack(spacing: 0) {
|
||||
GeometryReader { proxy in
|
||||
ScrollView {
|
||||
|
|
@ -7195,7 +7285,12 @@ struct VerticalTabsSidebar: View {
|
|||
tab: tab,
|
||||
index: index,
|
||||
isActive: tabManager.selectedTabId == tab.id,
|
||||
tabCount: tabManager.tabs.count,
|
||||
workspaceShortcutDigit: WorkspaceShortcutMapper.commandDigitForWorkspace(
|
||||
at: index,
|
||||
workspaceCount: workspaceCount
|
||||
),
|
||||
canCloseWorkspace: canCloseWorkspace,
|
||||
accessibilityWorkspaceCount: workspaceCount,
|
||||
unreadCount: notificationStore.unreadCount(forTabId: tab.id),
|
||||
latestNotificationText: {
|
||||
guard showsSidebarNotificationMessage,
|
||||
|
|
@ -8342,6 +8437,7 @@ private enum SidebarHelpMenuAction {
|
|||
case changelog
|
||||
case github
|
||||
case githubIssues
|
||||
case discord
|
||||
case checkForUpdates
|
||||
case sendFeedback
|
||||
case welcome
|
||||
|
|
@ -8842,6 +8938,7 @@ private struct SidebarHelpMenuButton: View {
|
|||
private let changelogURL = URL(string: "https://cmux.dev/docs/changelog")
|
||||
private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux")
|
||||
private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues")
|
||||
private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ")
|
||||
private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help")
|
||||
private let buttonSize: CGFloat = 22
|
||||
private let iconSize: CGFloat = 11
|
||||
|
|
@ -8886,7 +8983,7 @@ private struct SidebarHelpMenuButton: View {
|
|||
private var helpPopover: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"),
|
||||
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to cmux!"),
|
||||
action: .welcome,
|
||||
accessibilityIdentifier: "SidebarHelpMenuOptionWelcome",
|
||||
isExternalLink: false
|
||||
|
|
@ -8937,6 +9034,14 @@ private struct SidebarHelpMenuButton: View {
|
|||
isExternalLink: true
|
||||
)
|
||||
}
|
||||
if discordURL != nil {
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.discord", defaultValue: "Discord"),
|
||||
action: .discord,
|
||||
accessibilityIdentifier: "SidebarHelpMenuOptionDiscord",
|
||||
isExternalLink: true
|
||||
)
|
||||
}
|
||||
helpOptionButton(
|
||||
title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"),
|
||||
action: .checkForUpdates,
|
||||
|
|
@ -9027,6 +9132,9 @@ private struct SidebarHelpMenuButton: View {
|
|||
case .githubIssues:
|
||||
guard let githubIssuesURL else { return }
|
||||
NSWorkspace.shared.open(githubIssuesURL)
|
||||
case .discord:
|
||||
guard let discordURL else { return }
|
||||
NSWorkspace.shared.open(discordURL)
|
||||
case .checkForUpdates:
|
||||
Task { @MainActor in
|
||||
AppDelegate.shared?.checkForUpdates(nil)
|
||||
|
|
@ -9464,7 +9572,9 @@ private struct TabItemView: View, Equatable {
|
|||
lhs.tab === rhs.tab &&
|
||||
lhs.index == rhs.index &&
|
||||
lhs.isActive == rhs.isActive &&
|
||||
lhs.tabCount == rhs.tabCount &&
|
||||
lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit &&
|
||||
lhs.canCloseWorkspace == rhs.canCloseWorkspace &&
|
||||
lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount &&
|
||||
lhs.unreadCount == rhs.unreadCount &&
|
||||
lhs.latestNotificationText == rhs.latestNotificationText &&
|
||||
lhs.rowSpacing == rhs.rowSpacing &&
|
||||
|
|
@ -9480,7 +9590,9 @@ private struct TabItemView: View, Equatable {
|
|||
@ObservedObject var tab: Tab
|
||||
let index: Int
|
||||
let isActive: Bool
|
||||
let tabCount: Int
|
||||
let workspaceShortcutDigit: Int?
|
||||
let canCloseWorkspace: Bool
|
||||
let accessibilityWorkspaceCount: Int
|
||||
let unreadCount: Int
|
||||
let latestNotificationText: String?
|
||||
let rowSpacing: CGFloat
|
||||
|
|
@ -9583,12 +9695,8 @@ private struct TabItemView: View, Equatable {
|
|||
usesInvertedActiveForeground ? 1.0 : 0.9
|
||||
}
|
||||
|
||||
private var workspaceShortcutDigit: Int? {
|
||||
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabCount)
|
||||
}
|
||||
|
||||
private var showCloseButton: Bool {
|
||||
isHovering && tabCount > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints)
|
||||
isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints)
|
||||
}
|
||||
|
||||
private var workspaceShortcutLabel: String? {
|
||||
|
|
@ -10225,7 +10333,7 @@ private struct TabItemView: View, Equatable {
|
|||
}
|
||||
|
||||
private var accessibilityTitle: String {
|
||||
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabCount)")
|
||||
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)")
|
||||
}
|
||||
|
||||
private func moveBy(_ delta: Int) {
|
||||
|
|
@ -10289,16 +10397,7 @@ private struct TabItemView: View, Equatable {
|
|||
}
|
||||
|
||||
private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) {
|
||||
let idsToClose = targetIds.filter { id in
|
||||
guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return false }
|
||||
return allowPinned || !tab.isPinned
|
||||
}
|
||||
for id in idsToClose {
|
||||
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
|
||||
tabManager.closeWorkspaceWithConfirmation(tab)
|
||||
}
|
||||
}
|
||||
selectedTabIds.subtract(idsToClose)
|
||||
tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned)
|
||||
syncSelectionAfterMutation()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import Darwin
|
|||
import Sentry
|
||||
import Bonsplit
|
||||
import IOSurface
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
#if os(macOS)
|
||||
func cmuxShouldUseTransparentBackgroundWindow() -> Bool {
|
||||
|
|
@ -75,6 +76,7 @@ private enum GhosttyPasteboardHelper {
|
|||
)
|
||||
private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text")
|
||||
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
|
||||
private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!)
|
||||
|
||||
static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? {
|
||||
switch location {
|
||||
|
|
@ -99,13 +101,35 @@ private enum GhosttyPasteboardHelper {
|
|||
return value
|
||||
}
|
||||
|
||||
return pasteboard.string(forType: utf8PlainTextType)
|
||||
if let value = pasteboard.string(forType: utf8PlainTextType) {
|
||||
return value
|
||||
}
|
||||
|
||||
if hasImageData(in: pasteboard),
|
||||
let html = pasteboard.string(forType: .html),
|
||||
htmlHasNoVisibleText(html) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) {
|
||||
return htmlText
|
||||
}
|
||||
|
||||
if let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) {
|
||||
return rtfText
|
||||
}
|
||||
|
||||
return attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd)
|
||||
}
|
||||
|
||||
static func hasString(for location: ghostty_clipboard_e) -> Bool {
|
||||
guard let pasteboard = pasteboard(for: location) else { return false }
|
||||
if let text = stringContents(from: pasteboard), !text.isEmpty { return true }
|
||||
return clipboardHasImageOnly()
|
||||
let types = pasteboard.types ?? []
|
||||
if types.contains(.fileURL) || types.contains(.string) || types.contains(utf8PlainTextType)
|
||||
|| types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) {
|
||||
return true
|
||||
}
|
||||
return hasImageData(in: pasteboard)
|
||||
}
|
||||
|
||||
static func writeString(_ string: String, to location: ghostty_clipboard_e) {
|
||||
|
|
@ -122,40 +146,184 @@ private enum GhosttyPasteboardHelper {
|
|||
return result
|
||||
}
|
||||
|
||||
private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
|
||||
private static func attributedStringContents(
|
||||
from pasteboard: NSPasteboard,
|
||||
type: NSPasteboard.PasteboardType,
|
||||
documentType: NSAttributedString.DocumentType
|
||||
) -> String? {
|
||||
let attributed = attributedString(
|
||||
from: pasteboard,
|
||||
type: type,
|
||||
documentType: documentType
|
||||
)
|
||||
|
||||
/// Quick check: does the clipboard have image data and no text?
|
||||
static func clipboardHasImageOnly() -> Bool {
|
||||
let pb = NSPasteboard.general
|
||||
let types = pb.types ?? []
|
||||
let hasText = types.contains(.string) || types.contains(.html)
|
||||
|| types.contains(.rtf) || types.contains(.rtfd)
|
||||
if hasText { return false }
|
||||
return types.contains(.tiff) || types.contains(.png)
|
||||
let sanitized = attributed?.string
|
||||
.split(separator: objectReplacementCharacter, omittingEmptySubsequences: false)
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard let sanitized, !sanitized.isEmpty else { return nil }
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/// When the clipboard contains only image data (no text/HTML), saves it as
|
||||
/// a temporary PNG file and returns the shell-escaped file path. Returns nil
|
||||
/// if the clipboard contains text or no image.
|
||||
static func saveClipboardImageIfNeeded() -> String? {
|
||||
let pb = NSPasteboard.general
|
||||
let types = pb.types ?? []
|
||||
private static func attributedString(
|
||||
from pasteboard: NSPasteboard,
|
||||
type: NSPasteboard.PasteboardType,
|
||||
documentType: NSAttributedString.DocumentType
|
||||
) -> NSAttributedString? {
|
||||
let data =
|
||||
pasteboard.data(forType: type)
|
||||
?? pasteboard.string(forType: type)?.data(using: .utf8)
|
||||
guard let data else { return nil }
|
||||
|
||||
// If pasteboard has text/HTML, this is a normal copy.
|
||||
let hasText = types.contains(.string) || types.contains(.html)
|
||||
|| types.contains(.rtf) || types.contains(.rtfd)
|
||||
if hasText { return nil }
|
||||
return try? NSAttributedString(
|
||||
data: data,
|
||||
options: [
|
||||
.documentType: documentType,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
],
|
||||
documentAttributes: nil
|
||||
)
|
||||
}
|
||||
|
||||
// Check for image types (TIFF from screenshots, PNG from some tools).
|
||||
guard types.contains(.tiff) || types.contains(.png) else { return nil }
|
||||
guard let image = NSImage(pasteboard: pb),
|
||||
let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
|
||||
private static func rtfdAttachmentImageRepresentation(
|
||||
in pasteboard: NSPasteboard
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
guard let attributed = attributedString(
|
||||
from: pasteboard,
|
||||
type: .rtfd,
|
||||
documentType: .rtfd
|
||||
) else { return nil }
|
||||
|
||||
guard pngData.count <= maxClipboardImageSize else {
|
||||
var result: (data: Data, fileExtension: String)?
|
||||
attributed.enumerateAttribute(
|
||||
.attachment,
|
||||
in: NSRange(location: 0, length: attributed.length)
|
||||
) { value, _, stop in
|
||||
guard let attachment = value as? NSTextAttachment else { return }
|
||||
|
||||
if let fileWrapper = attachment.fileWrapper,
|
||||
let data = fileWrapper.regularFileContents,
|
||||
let imageRepresentation = imageAttachmentRepresentation(
|
||||
data: data,
|
||||
preferredFilename: fileWrapper.preferredFilename
|
||||
) {
|
||||
result = imageRepresentation
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func imageAttachmentRepresentation(
|
||||
data: Data,
|
||||
preferredFilename: String?
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
let pathExtension =
|
||||
(preferredFilename as NSString?)?.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
?? ""
|
||||
if let type = !pathExtension.isEmpty ? UTType(filenameExtension: pathExtension) : nil,
|
||||
type.conforms(to: .image),
|
||||
let fileExtension = type.preferredFilenameExtension ?? nonEmpty(pathExtension) {
|
||||
return (data, fileExtension)
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let typeIdentifier = CGImageSourceGetType(imageSource) as String?,
|
||||
let type = UTType(typeIdentifier),
|
||||
type.conforms(to: .image),
|
||||
let fileExtension = type.preferredFilenameExtension else { return nil }
|
||||
return (data, fileExtension)
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func hasImageData(in pasteboard: NSPasteboard) -> Bool {
|
||||
let types = pasteboard.types ?? []
|
||||
if types.contains(.tiff) || types.contains(.png) {
|
||||
return true
|
||||
}
|
||||
|
||||
return types.contains { type in
|
||||
guard let utType = UTType(type.rawValue) else { return false }
|
||||
return utType.conforms(to: .image)
|
||||
}
|
||||
}
|
||||
|
||||
private static func directImageRepresentation(
|
||||
in pasteboard: NSPasteboard
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
if let pngData = pasteboard.data(forType: .png) {
|
||||
return (pngData, "png")
|
||||
}
|
||||
|
||||
for type in pasteboard.types ?? [] {
|
||||
guard type != .png,
|
||||
type != .tiff,
|
||||
let utType = UTType(type.rawValue),
|
||||
utType.conforms(to: .image),
|
||||
let imageData = pasteboard.data(forType: type),
|
||||
let fileExtension = utType.preferredFilenameExtension,
|
||||
!fileExtension.isEmpty else { continue }
|
||||
return (imageData, fileExtension)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func htmlHasNoVisibleText(_ html: String) -> Bool {
|
||||
let withoutComments = html.replacingOccurrences(
|
||||
of: "<!--[\\s\\S]*?-->",
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
let withoutTags = withoutComments.replacingOccurrences(
|
||||
of: "<[^>]+>",
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
let normalized = withoutTags
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return normalized.isEmpty
|
||||
}
|
||||
|
||||
/// When the clipboard contains only image data (or rich text that resolves to
|
||||
/// an attachment-only image), saves it as a temporary image file and returns the
|
||||
/// shell-escaped file path. Returns nil if the clipboard contains text or no image.
|
||||
static func saveClipboardImageIfNeeded(
|
||||
from pasteboard: NSPasteboard = .general,
|
||||
assumeNoText: Bool = false
|
||||
) -> String? {
|
||||
if !assumeNoText && stringContents(from: pasteboard) != nil { return nil }
|
||||
|
||||
let imageData: Data
|
||||
let fileExtension: String
|
||||
if let directImage = directImageRepresentation(in: pasteboard) {
|
||||
imageData = directImage.data
|
||||
fileExtension = directImage.fileExtension
|
||||
} else if let rtfdAttachment = rtfdAttachmentImageRepresentation(in: pasteboard) {
|
||||
imageData = rtfdAttachment.data
|
||||
fileExtension = rtfdAttachment.fileExtension
|
||||
} else {
|
||||
guard hasImageData(in: pasteboard),
|
||||
let image = NSImage(pasteboard: pasteboard),
|
||||
let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
|
||||
imageData = pngData
|
||||
fileExtension = "png"
|
||||
}
|
||||
|
||||
let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
|
||||
guard imageData.count <= maxClipboardImageSize else {
|
||||
#if DEBUG
|
||||
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.count)")
|
||||
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
|
@ -164,11 +332,11 @@ private enum GhosttyPasteboardHelper {
|
|||
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png"
|
||||
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
|
||||
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename)
|
||||
|
||||
do {
|
||||
try pngData.write(to: URL(fileURLWithPath: path))
|
||||
try imageData.write(to: URL(fileURLWithPath: path))
|
||||
} catch {
|
||||
#if DEBUG
|
||||
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
|
||||
|
|
@ -180,6 +348,16 @@ private enum GhosttyPasteboardHelper {
|
|||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> String? {
|
||||
GhosttyPasteboardHelper.stringContents(from: pasteboard)
|
||||
}
|
||||
|
||||
func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? {
|
||||
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard)
|
||||
}
|
||||
#endif
|
||||
|
||||
enum TerminalOpenURLTarget: Equatable {
|
||||
case embeddedBrowser(URL)
|
||||
case external(URL)
|
||||
|
|
@ -877,7 +1055,11 @@ class GhosttyApp {
|
|||
|
||||
// When clipboard has only image data (e.g. screenshot), save as temp
|
||||
// PNG and paste the file path so CLI tools can receive images.
|
||||
if value.isEmpty, let imagePath = GhosttyPasteboardHelper.saveClipboardImageIfNeeded() {
|
||||
if value.isEmpty,
|
||||
let imagePath = pasteboard.flatMap({
|
||||
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: $0, assumeNoText: true)
|
||||
})
|
||||
{
|
||||
value = imagePath
|
||||
}
|
||||
|
||||
|
|
@ -2329,6 +2511,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
private let surfaceContext: ghostty_surface_context_e
|
||||
private let configTemplate: ghostty_surface_config_s?
|
||||
private let workingDirectory: String?
|
||||
var requestedWorkingDirectory: String? { workingDirectory }
|
||||
private var additionalEnvironment: [String: String]
|
||||
let hostedView: GhosttySurfaceScrollView
|
||||
private let surfaceView: GhosttyNSView
|
||||
|
|
@ -2341,6 +2524,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
private let maxPendingTextBytes = 1_048_576
|
||||
private var backgroundSurfaceStartQueued = false
|
||||
private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>?
|
||||
#if DEBUG
|
||||
private var needsConfirmCloseOverrideForTesting: Bool?
|
||||
#endif
|
||||
private enum PortalLifecycleState: String {
|
||||
case live
|
||||
case closing
|
||||
|
|
@ -2844,6 +3030,27 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
env["ZDOTDIR"] = integrationDir
|
||||
} else if shellName == "bash" {
|
||||
if GhosttyApp.shared.shellIntegrationMode() != "none" {
|
||||
env["CMUX_LOAD_GHOSTTY_BASH_INTEGRATION"] = "1"
|
||||
}
|
||||
// macOS ships /bin/bash 3.2, where Ghostty's automatic bash
|
||||
// integration is unsupported and HOME-based wrapper startup is
|
||||
// not reliable. Bootstrap cmux bash integration on the first
|
||||
// interactive prompt instead.
|
||||
env["PROMPT_COMMAND"] = """
|
||||
unset PROMPT_COMMAND; \
|
||||
if [[ "${CMUX_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \
|
||||
_cmux_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \
|
||||
[[ -r "$_cmux_ghostty_bash" ]] && source "$_cmux_ghostty_bash"; \
|
||||
fi; \
|
||||
if [[ "${CMUX_SHELL_INTEGRATION:-1}" != "0" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" ]]; then \
|
||||
_cmux_bash_integration="$CMUX_SHELL_INTEGRATION_DIR/cmux-bash-integration.bash"; \
|
||||
[[ -r "$_cmux_bash_integration" ]] && source "$_cmux_bash_integration"; \
|
||||
fi; \
|
||||
unset _cmux_ghostty_bash _cmux_bash_integration; \
|
||||
if declare -F _cmux_prompt_command >/dev/null 2>&1; then _cmux_prompt_command; fi
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3091,6 +3298,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
func needsConfirmClose() -> Bool {
|
||||
#if DEBUG
|
||||
if let needsConfirmCloseOverrideForTesting {
|
||||
return needsConfirmCloseOverrideForTesting
|
||||
}
|
||||
#endif
|
||||
guard let surface = surface else { return false }
|
||||
return ghostty_surface_needs_confirm_quit(surface)
|
||||
}
|
||||
|
|
@ -3209,6 +3421,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
func setNeedsConfirmCloseOverrideForTesting(_ value: Bool?) {
|
||||
needsConfirmCloseOverrideForTesting = value
|
||||
}
|
||||
|
||||
/// Test-only helper to deterministically simulate a released runtime surface.
|
||||
@MainActor
|
||||
func releaseSurfaceForTesting() {
|
||||
|
|
|
|||
|
|
@ -2132,6 +2132,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
}
|
||||
|
||||
func triggerFlash() {
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
focusFlashToken &+= 1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ final class MarkdownPanel: Panel, ObservableObject {
|
|||
}
|
||||
|
||||
func triggerFlash() {
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
focusFlashToken += 1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
surface.hostedView
|
||||
}
|
||||
|
||||
var requestedWorkingDirectory: String? {
|
||||
surface.requestedWorkingDirectory
|
||||
}
|
||||
|
||||
init(workspaceId: UUID, surface: TerminalSurface) {
|
||||
self.id = surface.id
|
||||
self.workspaceId = workspaceId
|
||||
|
|
@ -193,10 +197,12 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
}
|
||||
|
||||
func triggerFlash() {
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
hostedView.triggerFlash()
|
||||
}
|
||||
|
||||
func triggerNotificationDismissFlash() {
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
hostedView.triggerFlash(style: .notificationDismiss)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import AppKit
|
|||
/// View for rendering a terminal panel
|
||||
struct TerminalPanelView: View {
|
||||
@ObservedObject var panel: TerminalPanel
|
||||
@AppStorage(NotificationPaneRingSettings.enabledKey)
|
||||
private var notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
|
||||
let isFocused: Bool
|
||||
let isVisibleInUI: Bool
|
||||
let portalPriority: Int
|
||||
|
|
@ -23,7 +25,7 @@ struct TerminalPanelView: View {
|
|||
isVisibleInUI: isVisibleInUI,
|
||||
portalZPriority: portalPriority,
|
||||
showsInactiveOverlay: isSplit && !isFocused,
|
||||
showsUnreadNotificationRing: hasUnreadNotification,
|
||||
showsUnreadNotificationRing: hasUnreadNotification && notificationPaneRingEnabled,
|
||||
inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
|
||||
inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
|
||||
searchState: panel.searchState,
|
||||
|
|
|
|||
|
|
@ -643,6 +643,33 @@ class TabManager: ObservableObject {
|
|||
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? {
|
||||
willSet {
|
||||
#if DEBUG
|
||||
guard newValue != selectedTabId else {
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
debugPreparedWorkspaceSwitchTarget = nil
|
||||
return
|
||||
}
|
||||
|
||||
if debugPreparedWorkspaceSwitchTarget == newValue {
|
||||
debugPreparedWorkspaceSwitchTarget = nil
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
} else {
|
||||
let trigger = (debugPendingWorkspaceSwitchTarget == newValue
|
||||
? debugPendingWorkspaceSwitchTrigger
|
||||
: nil) ?? "direct"
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
debugBeginWorkspaceSwitch(
|
||||
trigger: trigger,
|
||||
from: selectedTabId,
|
||||
to: newValue
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
didSet {
|
||||
guard selectedTabId != oldValue else { return }
|
||||
sentryBreadcrumb("workspace.switch", data: [
|
||||
|
|
@ -713,10 +740,24 @@ class TabManager: ObservableObject {
|
|||
private var workspaceCycleGeneration: UInt64 = 0
|
||||
private var workspaceCycleCooldownTask: Task<Void, Never>?
|
||||
private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)?
|
||||
private var sidebarSelectedWorkspaceIds: Set<UUID> = []
|
||||
var confirmCloseHandler: ((String, String, Bool) -> Bool)?
|
||||
private struct WorkspaceCreationSnapshot {
|
||||
let tabs: [Workspace]
|
||||
let selectedTabId: UUID?
|
||||
|
||||
var selectedWorkspace: Workspace? {
|
||||
guard let selectedTabId else { return nil }
|
||||
return tabs.first(where: { $0.id == selectedTabId })
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
private var debugWorkspaceSwitchCounter: UInt64 = 0
|
||||
private var debugWorkspaceSwitchId: UInt64 = 0
|
||||
private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0
|
||||
private var debugPendingWorkspaceSwitchTrigger: String?
|
||||
private var debugPendingWorkspaceSwitchTarget: UUID?
|
||||
private var debugPreparedWorkspaceSwitchTarget: UUID?
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -883,25 +924,32 @@ class TabManager: ObservableObject {
|
|||
placementOverride: NewWorkspacePlacement? = nil,
|
||||
autoWelcomeIfNeeded: Bool = true
|
||||
) -> Workspace {
|
||||
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
|
||||
// Snapshot current published state once so workspace creation doesn't repeatedly
|
||||
// bounce through Combine-backed accessors while we're preparing the new workspace.
|
||||
let snapshot = workspaceCreationSnapshot()
|
||||
let nextTabCount = snapshot.tabs.count + 1
|
||||
sentryBreadcrumb("workspace.create", data: ["tabCount": nextTabCount])
|
||||
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
|
||||
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab()
|
||||
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
|
||||
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab(snapshot: snapshot)
|
||||
let inheritedConfig = inheritedTerminalConfigForNewWorkspace(snapshot: snapshot)
|
||||
let ordinal = Self.nextPortOrdinal
|
||||
Self.nextPortOrdinal += 1
|
||||
let newWorkspace = Workspace(
|
||||
title: "Terminal \(tabs.count + 1)",
|
||||
title: "Terminal \(nextTabCount)",
|
||||
workingDirectory: workingDirectory,
|
||||
portOrdinal: ordinal,
|
||||
configTemplate: inheritedConfig
|
||||
)
|
||||
newWorkspace.owningTabManager = self
|
||||
wireClosedBrowserTracking(for: newWorkspace)
|
||||
let insertIndex = newTabInsertIndex(placementOverride: placementOverride)
|
||||
if insertIndex >= 0 && insertIndex <= tabs.count {
|
||||
tabs.insert(newWorkspace, at: insertIndex)
|
||||
let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
|
||||
var updatedTabs = snapshot.tabs
|
||||
if insertIndex >= 0 && insertIndex <= updatedTabs.count {
|
||||
updatedTabs.insert(newWorkspace, at: insertIndex)
|
||||
} else {
|
||||
tabs.append(newWorkspace)
|
||||
updatedTabs.append(newWorkspace)
|
||||
}
|
||||
tabs = updatedTabs
|
||||
if let explicitWorkingDirectory,
|
||||
let terminalPanel = newWorkspace.focusedTerminalPanel {
|
||||
scheduleInitialWorkspaceGitMetadataRefresh(
|
||||
|
|
@ -915,6 +963,9 @@ class TabManager: ObservableObject {
|
|||
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
|
||||
}
|
||||
if select {
|
||||
#if DEBUG
|
||||
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
|
||||
#endif
|
||||
selectedTabId = newWorkspace.id
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidFocusTab,
|
||||
|
|
@ -925,8 +976,8 @@ class TabManager: ObservableObject {
|
|||
#if DEBUG
|
||||
UITestRecorder.incrementInt("addTabInvocations")
|
||||
UITestRecorder.record([
|
||||
"tabCount": String(tabs.count),
|
||||
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "")
|
||||
"tabCount": String(updatedTabs.count),
|
||||
"selectedTabId": select ? newWorkspace.id.uuidString : (snapshot.selectedTabId?.uuidString ?? "")
|
||||
])
|
||||
#endif
|
||||
if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) {
|
||||
|
|
@ -1154,7 +1205,20 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
|
||||
guard let workspace = selectedWorkspace else { return nil }
|
||||
terminalPanelForWorkspaceConfigInheritanceSource(snapshot: workspaceCreationSnapshot())
|
||||
}
|
||||
|
||||
private func workspaceCreationSnapshot() -> WorkspaceCreationSnapshot {
|
||||
WorkspaceCreationSnapshot(
|
||||
tabs: tabs,
|
||||
selectedTabId: selectedTabId
|
||||
)
|
||||
}
|
||||
|
||||
private func terminalPanelForWorkspaceConfigInheritanceSource(
|
||||
snapshot: WorkspaceCreationSnapshot
|
||||
) -> TerminalPanel? {
|
||||
guard let workspace = snapshot.selectedWorkspace else { return nil }
|
||||
if let focusedTerminal = workspace.focusedTerminalPanel {
|
||||
return focusedTerminal
|
||||
}
|
||||
|
|
@ -1169,13 +1233,19 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
|
||||
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
|
||||
inheritedTerminalConfigForNewWorkspace(snapshot: workspaceCreationSnapshot())
|
||||
}
|
||||
|
||||
private func inheritedTerminalConfigForNewWorkspace(
|
||||
snapshot: WorkspaceCreationSnapshot
|
||||
) -> ghostty_surface_config_s? {
|
||||
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource(snapshot: snapshot)?.surface.surface {
|
||||
return cmuxInheritedSurfaceConfig(
|
||||
sourceSurface: sourceSurface,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_TAB
|
||||
)
|
||||
}
|
||||
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
|
||||
if let fallbackFontPoints = snapshot.selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
|
||||
var config = ghostty_surface_config_new()
|
||||
config.font_size = fallbackFontPoints
|
||||
return config
|
||||
|
|
@ -1191,24 +1261,36 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int {
|
||||
newTabInsertIndex(snapshot: workspaceCreationSnapshot(), placementOverride: placementOverride)
|
||||
}
|
||||
|
||||
private func newTabInsertIndex(
|
||||
snapshot: WorkspaceCreationSnapshot,
|
||||
placementOverride: NewWorkspacePlacement? = nil
|
||||
) -> Int {
|
||||
let placement = placementOverride ?? WorkspacePlacementSettings.current()
|
||||
let pinnedCount = tabs.filter { $0.isPinned }.count
|
||||
let selectedIndex = selectedTabId.flatMap { tabId in
|
||||
tabs.firstIndex(where: { $0.id == tabId })
|
||||
let pinnedCount = snapshot.tabs.filter { $0.isPinned }.count
|
||||
let selectedIndex = snapshot.selectedTabId.flatMap { tabId in
|
||||
snapshot.tabs.firstIndex(where: { $0.id == tabId })
|
||||
}
|
||||
let selectedIsPinned = selectedIndex.map { tabs[$0].isPinned } ?? false
|
||||
let selectedIsPinned = selectedIndex.map { snapshot.tabs[$0].isPinned } ?? false
|
||||
return WorkspacePlacementSettings.insertionIndex(
|
||||
placement: placement,
|
||||
selectedIndex: selectedIndex,
|
||||
selectedIsPinned: selectedIsPinned,
|
||||
pinnedCount: pinnedCount,
|
||||
totalCount: tabs.count
|
||||
totalCount: snapshot.tabs.count
|
||||
)
|
||||
}
|
||||
|
||||
private func preferredWorkingDirectoryForNewTab() -> String? {
|
||||
guard let selectedTabId,
|
||||
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
|
||||
preferredWorkingDirectoryForNewTab(snapshot: workspaceCreationSnapshot())
|
||||
}
|
||||
|
||||
private func preferredWorkingDirectoryForNewTab(
|
||||
snapshot: WorkspaceCreationSnapshot
|
||||
) -> String? {
|
||||
guard let tab = snapshot.selectedWorkspace else {
|
||||
return nil
|
||||
}
|
||||
let focusedDirectory = tab.focusedPanelId
|
||||
|
|
@ -1322,6 +1404,15 @@ class TabManager: ObservableObject {
|
|||
tab.updatePanelDirectory(panelId: surfaceId, directory: normalized)
|
||||
}
|
||||
|
||||
func updateSurfaceShellActivity(
|
||||
tabId: UUID,
|
||||
surfaceId: UUID,
|
||||
state: Workspace.PanelShellActivityState
|
||||
) {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
||||
tab.updatePanelShellActivityState(panelId: surfaceId, state: state)
|
||||
}
|
||||
|
||||
private func normalizeDirectory(_ directory: String) -> String {
|
||||
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return directory }
|
||||
|
|
@ -1338,10 +1429,12 @@ class TabManager: ObservableObject {
|
|||
guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return }
|
||||
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspace.id)
|
||||
sidebarSelectedWorkspaceIds.remove(workspace.id)
|
||||
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
|
||||
unwireClosedBrowserTracking(for: workspace)
|
||||
workspace.teardownAllPanels()
|
||||
workspace.owningTabManager = nil
|
||||
|
||||
tabs.remove(at: index)
|
||||
|
||||
|
|
@ -1360,9 +1453,11 @@ class TabManager: ObservableObject {
|
|||
func detachWorkspace(tabId: UUID) -> Workspace? {
|
||||
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
|
||||
clearInitialWorkspaceGitProbe(workspaceId: tabId)
|
||||
sidebarSelectedWorkspaceIds.remove(tabId)
|
||||
|
||||
let removed = tabs.remove(at: index)
|
||||
unwireClosedBrowserTracking(for: removed)
|
||||
removed.owningTabManager = nil
|
||||
lastFocusedPanelByTab.removeValue(forKey: removed.id)
|
||||
|
||||
if tabs.isEmpty {
|
||||
|
|
@ -1381,6 +1476,7 @@ class TabManager: ObservableObject {
|
|||
|
||||
/// Attach an existing workspace to this window.
|
||||
func attachWorkspace(_ workspace: Workspace, at index: Int? = nil, select: Bool = true) {
|
||||
workspace.owningTabManager = self
|
||||
wireClosedBrowserTracking(for: workspace)
|
||||
let insertIndex: Int = {
|
||||
guard let index else { return tabs.count }
|
||||
|
|
@ -1441,6 +1537,11 @@ class TabManager: ObservableObject {
|
|||
#if DEBUG
|
||||
UITestRecorder.incrementInt("closeTabInvocations")
|
||||
#endif
|
||||
let sidebarSelectionIds = orderedSidebarSelectedWorkspaceIds()
|
||||
if sidebarSelectionIds.count > 1 {
|
||||
closeWorkspacesWithConfirmation(sidebarSelectionIds, allowPinned: true)
|
||||
return
|
||||
}
|
||||
guard let selectedId = selectedTabId,
|
||||
let workspace = tabs.first(where: { $0.id == selectedId }) else { return }
|
||||
closeWorkspaceWithConfirmation(workspace)
|
||||
|
|
@ -1455,7 +1556,36 @@ class TabManager: ObservableObject {
|
|||
closeWorkspaceWithConfirmation(workspace)
|
||||
}
|
||||
|
||||
func setSidebarSelectedWorkspaceIds(_ workspaceIds: Set<UUID>) {
|
||||
let existingIds = Set(tabs.map(\.id))
|
||||
sidebarSelectedWorkspaceIds = workspaceIds.intersection(existingIds)
|
||||
}
|
||||
|
||||
func closeWorkspacesWithConfirmation(_ workspaceIds: [UUID], allowPinned: Bool) {
|
||||
let workspaces = orderedClosableWorkspaces(workspaceIds, allowPinned: allowPinned)
|
||||
guard !workspaces.isEmpty else { return }
|
||||
guard workspaces.count > 1 else {
|
||||
closeWorkspaceWithConfirmation(workspaces[0])
|
||||
return
|
||||
}
|
||||
|
||||
let plan = closeWorkspacesPlan(for: workspaces)
|
||||
guard confirmClose(
|
||||
title: plan.title,
|
||||
message: plan.message,
|
||||
acceptCmdD: plan.acceptCmdD
|
||||
) else { return }
|
||||
|
||||
for workspace in plan.workspaces {
|
||||
guard tabs.contains(where: { $0.id == workspace.id }) else { continue }
|
||||
closeWorkspaceIfRunningProcess(workspace, requiresConfirmation: false)
|
||||
}
|
||||
}
|
||||
|
||||
func selectWorkspace(_ workspace: Workspace) {
|
||||
#if DEBUG
|
||||
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
|
||||
#endif
|
||||
selectedTabId = workspace.id
|
||||
}
|
||||
|
||||
|
|
@ -1463,6 +1593,11 @@ class TabManager: ObservableObject {
|
|||
func selectTab(_ tab: Workspace) { selectWorkspace(tab) }
|
||||
|
||||
private func confirmClose(title: String, message: String, acceptCmdD: Bool) -> Bool {
|
||||
if let confirmCloseHandler {
|
||||
return confirmCloseHandler(title, message, acceptCmdD)
|
||||
}
|
||||
_ = acceptCmdD
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
alert.informativeText = message
|
||||
|
|
@ -1470,15 +1605,18 @@ class TabManager: ObservableObject {
|
|||
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
|
||||
// conflicting with app-level Cmd+D (split right) during normal usage.
|
||||
if acceptCmdD, let closeButton = alert.buttons.first {
|
||||
closeButton.keyEquivalent = "d"
|
||||
closeButton.keyEquivalentModifierMask = [.command]
|
||||
|
||||
// Keep Return/Enter behavior by explicitly setting the default button cell.
|
||||
if let closeButton = alert.buttons.first {
|
||||
closeButton.keyEquivalent = "\r"
|
||||
closeButton.keyEquivalentModifierMask = []
|
||||
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
|
||||
alert.window.initialFirstResponder = closeButton
|
||||
}
|
||||
if let cancelButton = alert.buttons.dropFirst().first {
|
||||
cancelButton.keyEquivalent = "\u{1b}"
|
||||
}
|
||||
|
||||
if NSApp.activationPolicy() == .regular {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
return alert.runModal() == .alertFirstButtonReturn
|
||||
|
|
@ -1490,6 +1628,13 @@ class TabManager: ObservableObject {
|
|||
let titles: [String]
|
||||
}
|
||||
|
||||
private struct CloseWorkspacesPlan {
|
||||
let workspaces: [Workspace]
|
||||
let title: String
|
||||
let message: String
|
||||
let acceptCmdD: Bool
|
||||
}
|
||||
|
||||
private func closeOtherTabsInFocusedPanePlan() -> CloseOtherTabsInFocusedPanePlan? {
|
||||
guard let workspace = selectedWorkspace else { return nil }
|
||||
guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
|
||||
|
|
@ -1532,9 +1677,62 @@ class TabManager: ObservableObject {
|
|||
return String(localized: "tab.untitled", defaultValue: "Untitled Tab")
|
||||
}
|
||||
|
||||
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) {
|
||||
private func orderedClosableWorkspaces(_ workspaceIds: [UUID], allowPinned: Bool) -> [Workspace] {
|
||||
let targetIds = Set(workspaceIds)
|
||||
return tabs.compactMap { workspace in
|
||||
guard targetIds.contains(workspace.id) else { return nil }
|
||||
guard allowPinned || !workspace.isPinned else { return nil }
|
||||
return workspace
|
||||
}
|
||||
}
|
||||
|
||||
private func orderedSidebarSelectedWorkspaceIds() -> [UUID] {
|
||||
tabs.compactMap { workspace in
|
||||
sidebarSelectedWorkspaceIds.contains(workspace.id) ? workspace.id : nil
|
||||
}
|
||||
}
|
||||
|
||||
private func closeWorkspacesPlan(for workspaces: [Workspace]) -> CloseWorkspacesPlan {
|
||||
let willCloseWindow = workspaces.count == tabs.count
|
||||
let title = willCloseWindow
|
||||
? String(localized: "dialog.closeWindow.title", defaultValue: "Close window?")
|
||||
: String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
|
||||
let titleLines = workspaces
|
||||
.map { "• \(closeWorkspaceDisplayTitle($0.title))" }
|
||||
.joined(separator: "\n")
|
||||
let format = willCloseWindow
|
||||
? String(
|
||||
localized: "dialog.closeWorkspacesWindow.message",
|
||||
defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
|
||||
)
|
||||
: String(
|
||||
localized: "dialog.closeWorkspaces.message",
|
||||
defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
|
||||
)
|
||||
let message = String(format: format, locale: .current, Int64(workspaces.count), titleLines)
|
||||
return CloseWorkspacesPlan(
|
||||
workspaces: workspaces,
|
||||
title: title,
|
||||
message: message,
|
||||
acceptCmdD: willCloseWindow
|
||||
)
|
||||
}
|
||||
|
||||
private func closeWorkspaceDisplayTitle(_ title: String?) -> String {
|
||||
let collapsed = title?
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
.replacingOccurrences(of: "\r", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let collapsed, !collapsed.isEmpty {
|
||||
return collapsed
|
||||
}
|
||||
return String(localized: "workspace.displayName.fallback", defaultValue: "Workspace")
|
||||
}
|
||||
|
||||
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace, requiresConfirmation: Bool = true) {
|
||||
let willCloseWindow = tabs.count <= 1
|
||||
if workspaceNeedsConfirmClose(workspace),
|
||||
if requiresConfirmation,
|
||||
workspaceNeedsConfirmClose(workspace),
|
||||
!confirmClose(
|
||||
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."),
|
||||
|
|
@ -1544,13 +1742,27 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
if tabs.count <= 1 {
|
||||
// Last workspace in this window: close the window (Cmd+Shift+W behavior).
|
||||
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
|
||||
if let window {
|
||||
window.performClose(nil)
|
||||
} else {
|
||||
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
|
||||
}
|
||||
} else {
|
||||
closeWorkspace(workspace)
|
||||
}
|
||||
}
|
||||
|
||||
private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) {
|
||||
guard tab.panels[panelId] != nil else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut.skip tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=missingPanel"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in
|
||||
partial + tab.bonsplitController.tabs(inPane: paneId).count
|
||||
}
|
||||
|
|
@ -1568,73 +1780,13 @@ class TabManager: ObservableObject {
|
|||
)
|
||||
#endif
|
||||
|
||||
// Cmd+W closes the focused Bonsplit tab (a "tab" in the UI). When the workspace only has
|
||||
// a single tab left, closing it should close the workspace (and possibly the window),
|
||||
// rather than creating a replacement terminal.
|
||||
let effectiveSurfaceCount = max(tab.panels.count, bonsplitTabCount)
|
||||
let isLastTabInWorkspace = effectiveSurfaceCount <= 1
|
||||
if isLastTabInWorkspace {
|
||||
let willCloseWindow = tabs.count <= 1
|
||||
let needsConfirm = workspaceNeedsConfirmClose(tab)
|
||||
if needsConfirm {
|
||||
let message = willCloseWindow
|
||||
? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.")
|
||||
: String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.")
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=lastTab"
|
||||
)
|
||||
#endif
|
||||
guard confirmClose(
|
||||
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
|
||||
message: message,
|
||||
acceptCmdD: willCloseWindow
|
||||
) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=lastTabConfirmDismissed"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
|
||||
if willCloseWindow {
|
||||
AppDelegate.shared?.closeMainWindowContainingTabId(tab.id)
|
||||
} else {
|
||||
closeWorkspace(tab)
|
||||
}
|
||||
return
|
||||
// Route Cmd+W through Bonsplit/Workspace close handling so it matches the tab close
|
||||
// button, including shared confirmation, last-surface workspace/window-close behavior,
|
||||
// and the usual replacement-panel flow when the close does not collapse the workspace.
|
||||
if let surfaceId = tab.surfaceIdFromPanelId(panelId) {
|
||||
tab.markExplicitClose(surfaceId: surfaceId)
|
||||
}
|
||||
|
||||
if let terminalPanel = tab.terminalPanel(for: panelId),
|
||||
terminalPanel.needsConfirmClose() {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=terminalNeedsConfirm"
|
||||
)
|
||||
#endif
|
||||
guard confirmClose(
|
||||
title: 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
|
||||
dlog(
|
||||
"surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=terminalConfirmDismissed"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// We already confirmed (if needed); bypass Bonsplit's delegate gating.
|
||||
let closed = tab.closePanel(panelId, force: true)
|
||||
let closed = tab.closePanel(panelId)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
|
|
@ -1656,7 +1808,7 @@ class TabManager: ObservableObject {
|
|||
guard tab.panels[surfaceId] != nil else { return }
|
||||
|
||||
if let terminalPanel = tab.terminalPanel(for: surfaceId),
|
||||
terminalPanel.needsConfirmClose() {
|
||||
tab.panelNeedsConfirmClose(panelId: surfaceId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
guard confirmClose(
|
||||
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
|
||||
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
|
||||
|
|
@ -2030,6 +2182,9 @@ class TabManager: ObservableObject {
|
|||
// Keep selected-surface intent stable across selectedTabId didSet async restore.
|
||||
lastFocusedPanelByTab[tabId] = surfaceId
|
||||
}
|
||||
#if DEBUG
|
||||
debugPrimeWorkspaceSwitchTrigger("focus", to: tabId)
|
||||
#endif
|
||||
selectedTabId = tabId
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidFocusTab,
|
||||
|
|
@ -2097,13 +2252,7 @@ class TabManager: ObservableObject {
|
|||
let nextIndex = (currentIndex + 1) % tabs.count
|
||||
#if DEBUG
|
||||
let nextId = tabs[nextIndex].id
|
||||
debugWorkspaceSwitchCounter &+= 1
|
||||
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
|
||||
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
|
||||
dlog(
|
||||
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=next from=\(Self.debugShortWorkspaceId(currentId)) " +
|
||||
"to=\(Self.debugShortWorkspaceId(nextId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
|
||||
)
|
||||
debugPrepareWorkspaceSwitch("next", from: currentId, to: nextId)
|
||||
#endif
|
||||
activateWorkspaceCycleHotWindow()
|
||||
selectedTabId = tabs[nextIndex].id
|
||||
|
|
@ -2115,13 +2264,7 @@ class TabManager: ObservableObject {
|
|||
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
|
||||
#if DEBUG
|
||||
let prevId = tabs[prevIndex].id
|
||||
debugWorkspaceSwitchCounter &+= 1
|
||||
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
|
||||
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
|
||||
dlog(
|
||||
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=prev from=\(Self.debugShortWorkspaceId(currentId)) " +
|
||||
"to=\(Self.debugShortWorkspaceId(prevId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
|
||||
)
|
||||
debugPrepareWorkspaceSwitch("prev", from: currentId, to: prevId)
|
||||
#endif
|
||||
activateWorkspaceCycleHotWindow()
|
||||
selectedTabId = tabs[prevIndex].id
|
||||
|
|
@ -2194,6 +2337,40 @@ class TabManager: ObservableObject {
|
|||
return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime)
|
||||
}
|
||||
|
||||
private func debugPrimeWorkspaceSwitchTrigger(_ trigger: String, to target: UUID?) {
|
||||
guard selectedTabId != target else {
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
return
|
||||
}
|
||||
debugPendingWorkspaceSwitchTrigger = trigger
|
||||
debugPendingWorkspaceSwitchTarget = target
|
||||
}
|
||||
|
||||
private func debugPrepareWorkspaceSwitch(_ trigger: String, from: UUID?, to: UUID?) {
|
||||
guard from != to else {
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
debugPreparedWorkspaceSwitchTarget = nil
|
||||
return
|
||||
}
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
debugBeginWorkspaceSwitch(trigger: trigger, from: from, to: to)
|
||||
debugPreparedWorkspaceSwitchTarget = to
|
||||
}
|
||||
|
||||
private func debugBeginWorkspaceSwitch(trigger: String, from: UUID?, to: UUID?) {
|
||||
debugWorkspaceSwitchCounter &+= 1
|
||||
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
|
||||
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
|
||||
dlog(
|
||||
"ws.switch.begin id=\(debugWorkspaceSwitchId) trigger=\(trigger) " +
|
||||
"from=\(Self.debugShortWorkspaceId(from)) to=\(Self.debugShortWorkspaceId(to)) " +
|
||||
"hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
|
||||
)
|
||||
}
|
||||
|
||||
private static func debugShortWorkspaceId(_ id: UUID?) -> String {
|
||||
guard let id else { return "nil" }
|
||||
return String(id.uuidString.prefix(5))
|
||||
|
|
@ -2206,6 +2383,9 @@ class TabManager: ObservableObject {
|
|||
|
||||
func selectTab(at index: Int) {
|
||||
guard index >= 0 && index < tabs.count else { return }
|
||||
#if DEBUG
|
||||
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id)
|
||||
#endif
|
||||
selectedTabId = tabs[index].id
|
||||
}
|
||||
|
||||
|
|
@ -3937,6 +4117,7 @@ extension TabManager {
|
|||
workingDirectory: workspaceSnapshot.currentDirectory,
|
||||
portOrdinal: ordinal
|
||||
)
|
||||
workspace.owningTabManager = self
|
||||
workspace.restoreSessionSnapshot(workspaceSnapshot)
|
||||
wireClosedBrowserTracking(for: workspace)
|
||||
newTabs.append(workspace)
|
||||
|
|
@ -3946,6 +4127,7 @@ extension TabManager {
|
|||
let ordinal = Self.nextPortOrdinal
|
||||
Self.nextPortOrdinal += 1
|
||||
let fallback = Workspace(title: "Terminal 1", portOrdinal: ordinal)
|
||||
fallback.owningTabManager = self
|
||||
wireClosedBrowserTracking(for: fallback)
|
||||
newTabs.append(fallback)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,7 +320,9 @@ class TerminalController {
|
|||
private final class SocketFastPathState: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
|
||||
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
|
||||
private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:]
|
||||
private let maxTrackedDirectories = 4096
|
||||
private let maxTrackedShellStates = 4096
|
||||
|
||||
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
|
||||
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
||||
|
|
@ -335,6 +337,24 @@ class TerminalController {
|
|||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func shouldPublishShellActivity(
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
state: Workspace.PanelShellActivityState
|
||||
) -> Bool {
|
||||
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
||||
return queue.sync {
|
||||
if lastReportedShellStates[key] == state {
|
||||
return false
|
||||
}
|
||||
if lastReportedShellStates.count >= maxTrackedShellStates {
|
||||
lastReportedShellStates.removeAll(keepingCapacity: true)
|
||||
}
|
||||
lastReportedShellStates[key] = state
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let socketFastPathState = SocketFastPathState()
|
||||
|
|
@ -362,6 +382,21 @@ class TerminalController {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
nonisolated static func parseReportedShellActivityState(
|
||||
_ rawState: String
|
||||
) -> Workspace.PanelShellActivityState? {
|
||||
switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "prompt", "idle":
|
||||
return .promptIdle
|
||||
case "running", "busy", "command":
|
||||
return .commandRunning
|
||||
case "unknown", "clear":
|
||||
return .unknown
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Update which window's TabManager receives socket commands.
|
||||
/// This is used when the user switches between multiple terminal windows.
|
||||
func setActiveTabManager(_ tabManager: TabManager?) {
|
||||
|
|
@ -1456,6 +1491,9 @@ class TerminalController {
|
|||
case "ports_kick":
|
||||
return portsKick(args)
|
||||
|
||||
case "report_shell_state":
|
||||
return reportShellState(args)
|
||||
|
||||
case "report_pwd":
|
||||
return reportPwd(args)
|
||||
|
||||
|
|
@ -9705,6 +9743,7 @@ class TerminalController {
|
|||
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
|
||||
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
|
||||
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
|
||||
report_shell_state <prompt|running> [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command
|
||||
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
|
||||
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
|
||||
sidebar_state [--tab=X] - Dump sidebar metadata
|
||||
|
|
@ -13603,6 +13642,72 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func reportShellState(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard let rawState = parsed.positional.first, !rawState.isEmpty else {
|
||||
return "ERROR: Missing shell state — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
|
||||
}
|
||||
guard let state = Self.parseReportedShellActivityState(rawState) else {
|
||||
return "ERROR: Invalid shell state '\(rawState)' — expected prompt or running"
|
||||
}
|
||||
|
||||
if let scope = Self.explicitSocketScope(options: parsed.options) {
|
||||
guard Self.socketFastPathState.shouldPublishShellActivity(
|
||||
workspaceId: scope.workspaceId,
|
||||
panelId: scope.panelId,
|
||||
state: state
|
||||
) else {
|
||||
return "OK"
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return }
|
||||
tabManager.updateSurfaceShellActivity(tabId: scope.workspaceId, surfaceId: scope.panelId, state: state)
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedPanelId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
tabManager.updateSurfaceShellActivity(tabId: tab.id, surfaceId: surfaceId, state: state)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearPorts(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
var result = "OK"
|
||||
|
|
|
|||
|
|
@ -527,6 +527,23 @@ enum NotificationBadgeSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum NotificationPaneRingSettings {
|
||||
static let enabledKey = "notificationPaneRingEnabled"
|
||||
static let defaultEnabled = true
|
||||
}
|
||||
|
||||
enum NotificationPaneFlashSettings {
|
||||
static let enabledKey = "notificationPaneFlashEnabled"
|
||||
static let defaultEnabled = true
|
||||
|
||||
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: enabledKey) == nil {
|
||||
return defaultEnabled
|
||||
}
|
||||
return defaults.bool(forKey: enabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum TaggedRunBadgeSettings {
|
||||
static let environmentKey = "CMUX_TAG"
|
||||
private static let maxTagLength = 10
|
||||
|
|
|
|||
|
|
@ -952,6 +952,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
/// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore.
|
||||
var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)?
|
||||
weak var owningTabManager: TabManager?
|
||||
|
||||
|
||||
// Closing tabs mutates split layout immediately; terminal views handle their own AppKit
|
||||
|
|
@ -1004,6 +1005,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
|
||||
@Published var listeningPorts: [Int] = []
|
||||
var surfaceTTYNames: [UUID: String] = [:]
|
||||
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
|
||||
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
|
||||
|
||||
var focusedSurfaceId: UUID? { focusedPanelId }
|
||||
|
|
@ -1020,6 +1022,26 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
static let markdown = "markdown"
|
||||
}
|
||||
|
||||
enum PanelShellActivityState: String {
|
||||
case unknown
|
||||
case promptIdle
|
||||
case commandRunning
|
||||
}
|
||||
|
||||
nonisolated static func resolveCloseConfirmation(
|
||||
shellActivityState: PanelShellActivityState?,
|
||||
fallbackNeedsConfirmClose: Bool
|
||||
) -> Bool {
|
||||
switch shellActivityState ?? .unknown {
|
||||
case .promptIdle:
|
||||
return false
|
||||
case .commandRunning:
|
||||
return true
|
||||
case .unknown:
|
||||
return fallbackNeedsConfirmClose
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
|
||||
|
|
@ -1187,6 +1209,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
bonsplitController.onExternalTabDrop = { [weak self] request in
|
||||
self?.handleExternalTabDrop(request) ?? false
|
||||
}
|
||||
bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in
|
||||
self?.markExplicitClose(surfaceId: tabId)
|
||||
}
|
||||
|
||||
// Set ourselves as delegate
|
||||
bonsplitController.delegate = self
|
||||
|
|
@ -1233,6 +1258,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
/// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs.
|
||||
private var pendingCloseConfirmTabIds: Set<TabID> = []
|
||||
|
||||
/// Tab IDs whose next close attempt came from an explicit user close gesture
|
||||
/// (Cmd+W or the tab-strip X button), rather than an internal close/move flow.
|
||||
private var explicitUserCloseTabIds: Set<TabID> = []
|
||||
|
||||
/// Deterministic tab selection to apply after a tab closes.
|
||||
/// Keyed by the closing tab ID, value is the tab ID we want to select next.
|
||||
private var postCloseSelectTabId: [TabID: TabID] = [:]
|
||||
|
|
@ -1299,6 +1328,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
surfaceIdToPanelId[surfaceId]
|
||||
}
|
||||
|
||||
func markExplicitClose(surfaceId: TabID) {
|
||||
explicitUserCloseTabIds.insert(surfaceId)
|
||||
}
|
||||
|
||||
func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? {
|
||||
surfaceIdToPanelId.first { $0.value == panelId }?.key
|
||||
}
|
||||
|
|
@ -1650,6 +1683,26 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) {
|
||||
guard panels[panelId] != nil else { return }
|
||||
let previousState = panelShellActivityStates[panelId] ?? .unknown
|
||||
guard previousState != state else { return }
|
||||
panelShellActivityStates[panelId] = state
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.shellState workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool {
|
||||
Self.resolveCloseConfirmation(
|
||||
shellActivityState: panelShellActivityStates[panelId],
|
||||
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
|
||||
)
|
||||
}
|
||||
|
||||
func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) {
|
||||
let state = SidebarGitBranchState(branch: branch, isDirty: isDirty)
|
||||
let existing = panelGitBranches[panelId]
|
||||
|
|
@ -1791,6 +1844,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
||||
panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) }
|
||||
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
|
@ -2068,12 +2122,26 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
|
||||
|
||||
// Inherit working directory: prefer the source panel's reported cwd,
|
||||
// fall back to the workspace's current directory.
|
||||
let splitWorkingDirectory: String? = panelDirectories[panelId]
|
||||
?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? nil : currentDirectory)
|
||||
// then its requested startup cwd if shell integration has not reported
|
||||
// back yet, and finally fall back to the workspace's current directory.
|
||||
let splitWorkingDirectory: String? = {
|
||||
if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!panelDirectory.isEmpty {
|
||||
return panelDirectory
|
||||
}
|
||||
if let requestedWorkingDirectory = terminalPanel(for: panelId)?
|
||||
.requestedWorkingDirectory?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!requestedWorkingDirectory.isEmpty {
|
||||
return requestedWorkingDirectory
|
||||
}
|
||||
let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return workspaceDirectory.isEmpty ? nil : workspaceDirectory
|
||||
}()
|
||||
#if DEBUG
|
||||
dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")")
|
||||
dlog(
|
||||
"split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")"
|
||||
)
|
||||
#endif
|
||||
|
||||
// Create the new terminal panel.
|
||||
|
|
@ -3479,9 +3547,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
/// Check if any panel needs close confirmation
|
||||
func needsConfirmClose() -> Bool {
|
||||
for panel in panels.values {
|
||||
for (panelId, panel) in panels {
|
||||
if let terminalPanel = panel as? TerminalPanel,
|
||||
terminalPanel.needsConfirmClose() {
|
||||
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -4120,6 +4188,18 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
// MARK: - BonsplitDelegate
|
||||
|
||||
extension Workspace: BonsplitDelegate {
|
||||
@MainActor
|
||||
private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool {
|
||||
let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager
|
||||
guard panels.count <= 1,
|
||||
panelIdFromSurfaceId(tabId) != nil,
|
||||
let manager,
|
||||
manager.tabs.contains(where: { $0.id == id }) else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func confirmClosePanel(for tabId: TabID) async -> Bool {
|
||||
let alert = NSAlert()
|
||||
|
|
@ -4129,6 +4209,16 @@ extension Workspace: BonsplitDelegate {
|
|||
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
|
||||
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
||||
|
||||
if let closeButton = alert.buttons.first {
|
||||
closeButton.keyEquivalent = "\r"
|
||||
closeButton.keyEquivalentModifierMask = []
|
||||
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
|
||||
alert.window.initialFirstResponder = closeButton
|
||||
}
|
||||
if let cancelButton = alert.buttons.dropFirst().first {
|
||||
cancelButton.keyEquivalent = "\u{1b}"
|
||||
}
|
||||
|
||||
// Prefer a sheet if we can find a window, otherwise fall back to modal.
|
||||
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
|
||||
return await withCheckedContinuation { continuation in
|
||||
|
|
@ -4522,6 +4612,8 @@ extension Workspace: BonsplitDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil
|
||||
|
||||
if forceCloseTabIds.contains(tab.id) {
|
||||
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
|
||||
recordPostCloseSelection()
|
||||
|
|
@ -4535,6 +4627,12 @@ extension Workspace: BonsplitDelegate {
|
|||
return false
|
||||
}
|
||||
|
||||
if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) {
|
||||
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
|
||||
owningTabManager?.closeWorkspaceWithConfirmation(self)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the panel needs close confirmation
|
||||
guard let panelId = panelIdFromSurfaceId(tab.id),
|
||||
let terminalPanel = terminalPanel(for: panelId) else {
|
||||
|
|
@ -4546,7 +4644,7 @@ extension Workspace: BonsplitDelegate {
|
|||
// If confirmation is required, Bonsplit will call into this delegate and we must return false.
|
||||
// Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass
|
||||
// this gating on the second pass.
|
||||
if terminalPanel.needsConfirmClose() {
|
||||
if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
|
||||
if pendingCloseConfirmTabIds.contains(tab.id) {
|
||||
return false
|
||||
|
|
@ -4646,6 +4744,7 @@ extension Workspace: BonsplitDelegate {
|
|||
manualUnreadPanelIds.remove(panelId)
|
||||
manualUnreadMarkedAt.removeValue(forKey: panelId)
|
||||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
panelShellActivityStates.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
||||
|
|
@ -4653,6 +4752,7 @@ extension Workspace: BonsplitDelegate {
|
|||
if lastTerminalConfigInheritancePanelId == panelId {
|
||||
lastTerminalConfigInheritancePanelId = nil
|
||||
}
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
|
||||
|
||||
// Keep the workspace invariant for normal close paths.
|
||||
// Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can
|
||||
|
|
@ -4824,6 +4924,7 @@ extension Workspace: BonsplitDelegate {
|
|||
pinnedPanelIds.remove(panelId)
|
||||
manualUnreadPanelIds.remove(panelId)
|
||||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
panelShellActivityStates.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
surfaceListeningPorts.removeValue(forKey: panelId)
|
||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||
|
|
@ -4861,7 +4962,7 @@ extension Workspace: BonsplitDelegate {
|
|||
if forceCloseTabIds.contains(tab.id) { continue }
|
||||
if let panelId = panelIdFromSurfaceId(tab.id),
|
||||
let terminalPanel = terminalPanel(for: panelId),
|
||||
terminalPanel.needsConfirmClose() {
|
||||
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
pendingPaneClosePanelIds.removeValue(forKey: pane.id)
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -452,8 +452,9 @@ struct cmuxApp: App {
|
|||
Divider()
|
||||
|
||||
// Terminal semantics:
|
||||
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last
|
||||
// tab in the last workspace, it closes the window.
|
||||
// Cmd+W closes the focused tab/surface (with confirmation if needed). When that
|
||||
// was the last surface in the workspace, cmux removes the workspace and closes
|
||||
// the window if it was also the last workspace.
|
||||
Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) {
|
||||
closePanelOrWindow()
|
||||
}
|
||||
|
|
@ -881,11 +882,7 @@ struct cmuxApp: App {
|
|||
in manager: TabManager,
|
||||
allowPinned: Bool
|
||||
) {
|
||||
for workspaceId in workspaceIds {
|
||||
guard let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { continue }
|
||||
guard allowPinned || !workspace.isPinned else { continue }
|
||||
manager.closeWorkspaceWithConfirmation(workspace)
|
||||
}
|
||||
manager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned)
|
||||
}
|
||||
|
||||
private func closeOtherSelectedWorkspacePeers(in manager: TabManager) {
|
||||
|
|
@ -3070,6 +3067,9 @@ struct SettingsView: View {
|
|||
private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
|
||||
@AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand
|
||||
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
@AppStorage(NotificationPaneRingSettings.enabledKey) private var notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
|
||||
@AppStorage(NotificationPaneFlashSettings.enabledKey) private var notificationPaneFlashEnabled = NotificationPaneFlashSettings.defaultEnabled
|
||||
@AppStorage(MenuBarExtraSettings.showInMenuBarKey) private var showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar
|
||||
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
|
|
@ -3426,14 +3426,6 @@ struct SettingsView: View {
|
|||
VStack(alignment: .leading, spacing: 14) {
|
||||
SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"))
|
||||
SettingsCard {
|
||||
SettingsPickerRow(String(localized: "settings.app.theme", defaultValue: "Theme"), controlWidth: pickerColumnWidth, selection: $appearanceMode) {
|
||||
ForEach(AppearanceMode.visibleCases) { mode in
|
||||
Text(mode.displayName).tag(mode.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.app.language", defaultValue: "Language"),
|
||||
subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue
|
||||
|
|
@ -3465,6 +3457,15 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
ThemePickerRow(
|
||||
selectedMode: appearanceMode,
|
||||
onSelect: { mode in
|
||||
appearanceMode = mode.rawValue
|
||||
}
|
||||
)
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
AppIconPickerRow(
|
||||
selectedMode: appIconMode,
|
||||
onSelect: { mode in
|
||||
|
|
@ -3510,6 +3511,48 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"),
|
||||
subtitle: String(localized: "settings.app.showInMenuBar.subtitle", defaultValue: "Keep cmux in the menu bar for unread notifications and quick actions.")
|
||||
) {
|
||||
Toggle("", isOn: $showMenuBarExtra)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel(
|
||||
String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar")
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"),
|
||||
subtitle: String(localized: "settings.notifications.paneRing.subtitle", defaultValue: "Show a blue ring around panes with unread notifications.")
|
||||
) {
|
||||
Toggle("", isOn: $notificationPaneRingEnabled)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel(
|
||||
String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring")
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"),
|
||||
subtitle: String(localized: "settings.notifications.paneFlash.subtitle", defaultValue: "Briefly flash a blue outline when cmux highlights a pane.")
|
||||
) {
|
||||
Toggle("", isOn: $notificationPaneFlashEnabled)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel(
|
||||
String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash")
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Desktop Notifications",
|
||||
subtitle: notificationPermissionSubtitle
|
||||
|
|
@ -4407,6 +4450,9 @@ struct SettingsView: View {
|
|||
notificationCustomSoundErrorAlertMessage = ""
|
||||
notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand
|
||||
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
|
||||
notificationPaneFlashEnabled = NotificationPaneFlashSettings.defaultEnabled
|
||||
showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar
|
||||
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
ShortcutHintDebugSettings.resetVisibilityDefaults()
|
||||
|
|
@ -4683,6 +4729,193 @@ private struct SettingsCardNote: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct ThemeWindowThumbnail: View {
|
||||
let isDark: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let width = geo.size.width
|
||||
let height = geo.size.height
|
||||
|
||||
ZStack {
|
||||
// Wallpaper background
|
||||
if isDark {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.1, green: 0.1, blue: 0.3), Color(red: 0.05, green: 0.05, blue: 0.1)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: 0, y: height * 0.5))
|
||||
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
|
||||
path.addLine(to: CGPoint(x: width, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: 0))
|
||||
}
|
||||
.fill(LinearGradient(colors: [Color(red: 0.2, green: 0.2, blue: 0.6).opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
} else {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.6, green: 0.8, blue: 0.95), Color(red: 0.2, green: 0.4, blue: 0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: 0, y: height * 0.5))
|
||||
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
|
||||
path.addLine(to: CGPoint(x: width, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: 0))
|
||||
}
|
||||
.fill(LinearGradient(colors: [Color(red: 0.8, green: 0.9, blue: 1.0).opacity(0.6), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
}
|
||||
|
||||
// Menu bar
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "applelogo")
|
||||
.font(.system(size: max(height * 0.08, 6)))
|
||||
.foregroundColor(isDark ? .white : .black)
|
||||
.opacity(0.8)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, max(width * 0.04, 4))
|
||||
.frame(height: max(height * 0.12, 8))
|
||||
.background(.ultraThinMaterial)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Back window
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(isDark ? Color(white: 0.2) : Color(white: 0.9))
|
||||
.frame(height: max(height * 0.15, 8))
|
||||
ZStack(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(isDark ? Color(white: 0.15) : Color(white: 0.98))
|
||||
RoundedRectangle(cornerRadius: max(width * 0.02, 2), style: .continuous)
|
||||
.fill(Color.accentColor)
|
||||
.frame(height: max(height * 0.12, 6))
|
||||
.padding(max(width * 0.04, 4))
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.04, 4), style: .continuous))
|
||||
.frame(width: width * 0.65, height: height * 0.45)
|
||||
.shadow(color: .black.opacity(isDark ? 0.4 : 0.15), radius: 4, x: 0, y: 2)
|
||||
.offset(x: -width * 0.08, y: -height * 0.1)
|
||||
|
||||
// Front window with traffic lights
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(isDark ? Color(white: 0.18) : Color(white: 0.92))
|
||||
HStack(spacing: max(width * 0.025, 2)) {
|
||||
Circle().fill(Color(red: 1.0, green: 0.37, blue: 0.34)).frame(width: max(width * 0.04, 3))
|
||||
Circle().fill(Color(red: 1.0, green: 0.74, blue: 0.18)).frame(width: max(width * 0.04, 3))
|
||||
Circle().fill(Color(red: 0.15, green: 0.79, blue: 0.25)).frame(width: max(width * 0.04, 3))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, max(width * 0.04, 4))
|
||||
}
|
||||
.frame(height: max(height * 0.18, 10))
|
||||
Rectangle()
|
||||
.fill(isDark ? Color(white: 0.1) : .white)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.05, 5), style: .continuous))
|
||||
.shadow(color: .black.opacity(isDark ? 0.5 : 0.2), radius: 6, x: 0, y: 3)
|
||||
.frame(width: width * 0.75, height: height * 0.55)
|
||||
.offset(x: width * 0.12, y: height * 0.2)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThemePickerRow: View {
|
||||
let selectedMode: String
|
||||
let onSelect: (AppearanceMode) -> Void
|
||||
|
||||
private let thumbWidth: CGFloat = 76
|
||||
private let thumbHeight: CGFloat = 50
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(String(localized: "settings.app.theme", defaultValue: "Theme"))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(AppearanceMode.visibleCases) { mode in
|
||||
let isSelected = selectedMode == mode.rawValue
|
||||
Button {
|
||||
onSelect(mode)
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Group {
|
||||
if mode == .system {
|
||||
ZStack {
|
||||
ThemeWindowThumbnail(isDark: false)
|
||||
.mask(
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.frame(width: geo.size.width / 2, height: geo.size.height)
|
||||
.position(x: geo.size.width / 4, y: geo.size.height / 2)
|
||||
}
|
||||
)
|
||||
ThemeWindowThumbnail(isDark: true)
|
||||
.mask(
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.frame(width: geo.size.width / 2, height: geo.size.height)
|
||||
.position(x: geo.size.width * 0.75, y: geo.size.height / 2)
|
||||
}
|
||||
)
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(Color.primary.opacity(0.15))
|
||||
.frame(width: 1, height: geo.size.height)
|
||||
.position(x: geo.size.width / 2, y: geo.size.height / 2)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ThemeWindowThumbnail(isDark: mode == .dark)
|
||||
}
|
||||
}
|
||||
.frame(width: thumbWidth, height: thumbHeight)
|
||||
|
||||
Text(mode.displayName)
|
||||
.font(.system(size: 10))
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
.foregroundColor(isSelected ? .primary : .secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(isSelected
|
||||
? Color.accentColor.opacity(0.12)
|
||||
: Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppIconPickerRow: View {
|
||||
let selectedMode: String
|
||||
let onSelect: (AppIconMode) -> Void
|
||||
|
|
@ -4691,20 +4924,25 @@ private struct AppIconPickerRow: View {
|
|||
private let autoIconSize: CGFloat = 36
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon"))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon"))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
Text(String(localized: "settings.app.appIcon.subtitle", defaultValue: "Dock and app switcher"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(AppIconMode.allCases) { mode in
|
||||
let isSelected = selectedMode == mode.rawValue
|
||||
Button {
|
||||
onSelect(mode)
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 4) {
|
||||
Group {
|
||||
if mode == .automatic {
|
||||
// Show both icons overlapping
|
||||
ZStack {
|
||||
Image("AppIconLight")
|
||||
.resizable()
|
||||
|
|
@ -4730,25 +4968,29 @@ private struct AppIconPickerRow: View {
|
|||
}
|
||||
|
||||
Text(mode.displayName)
|
||||
.font(.system(size: 11))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(isSelected ? .primary : .secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.horizontal, 10)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(isSelected
|
||||
? Color.accentColor.opacity(0.12)
|
||||
: Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
|
|
|
|||
|
|
@ -235,6 +235,113 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses")
|
||||
}
|
||||
|
||||
func testAddWorkspaceInPreferredMainWindowPrunesOrphanedContextWithoutLiveWindow() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let orphanWindowId = UUID()
|
||||
let orphanManager = TabManager()
|
||||
let orphanSidebarState = SidebarState()
|
||||
let orphanSidebarSelectionState = SidebarSelectionState()
|
||||
|
||||
autoreleasepool {
|
||||
var orphanWindow: NSWindow? = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
orphanWindow?.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(orphanWindowId.uuidString)")
|
||||
appDelegate.registerMainWindow(
|
||||
orphanWindow!,
|
||||
windowId: orphanWindowId,
|
||||
tabManager: orphanManager,
|
||||
sidebarState: orphanSidebarState,
|
||||
sidebarSelectionState: orphanSidebarSelectionState
|
||||
)
|
||||
orphanWindow = nil
|
||||
}
|
||||
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertNil(appDelegate.mainWindow(for: orphanWindowId), "Test precondition: orphaned context should not have a live window")
|
||||
|
||||
let orphanCount = orphanManager.tabs.count
|
||||
XCTAssertNil(
|
||||
appDelegate.addWorkspaceInPreferredMainWindow(),
|
||||
"Workspace creation should refuse orphaned contexts with no live window"
|
||||
)
|
||||
XCTAssertEqual(orphanManager.tabs.count, orphanCount, "Orphaned manager must not receive a new workspace")
|
||||
XCTAssertNil(appDelegate.tabManagerFor(windowId: orphanWindowId), "Orphaned context should be pruned after failed resolution")
|
||||
}
|
||||
|
||||
func testCustomCmdTNewWorkspacePrunesOrphanedContextWithoutLiveWindow() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let existingWindowIds = mainWindowIds()
|
||||
let orphanWindowId = UUID()
|
||||
let orphanManager = TabManager()
|
||||
let orphanSidebarState = SidebarState()
|
||||
let orphanSidebarSelectionState = SidebarSelectionState()
|
||||
|
||||
autoreleasepool {
|
||||
var orphanWindow: NSWindow? = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
orphanWindow?.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(orphanWindowId.uuidString)")
|
||||
appDelegate.registerMainWindow(
|
||||
orphanWindow!,
|
||||
windowId: orphanWindowId,
|
||||
tabManager: orphanManager,
|
||||
sidebarState: orphanSidebarState,
|
||||
sidebarSelectionState: orphanSidebarSelectionState
|
||||
)
|
||||
orphanWindow = nil
|
||||
}
|
||||
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertNil(appDelegate.mainWindow(for: orphanWindowId), "Test precondition: orphaned context should not have a live window")
|
||||
|
||||
let orphanCount = orphanManager.tabs.count
|
||||
let remappedCmdT = StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
|
||||
|
||||
withTemporaryShortcut(action: .newTab, shortcut: remappedCmdT) {
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "t",
|
||||
modifiers: [.command],
|
||||
keyCode: 17, // kVK_ANSI_T
|
||||
windowNumber: 0
|
||||
) else {
|
||||
XCTFail("Failed to construct remapped Cmd+T event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
}
|
||||
|
||||
XCTAssertEqual(orphanManager.tabs.count, orphanCount, "Orphaned manager must not receive a new workspace from remapped Cmd+T")
|
||||
XCTAssertNil(appDelegate.tabManagerFor(windowId: orphanWindowId), "Remapped Cmd+T should prune the orphaned context after failed resolution")
|
||||
|
||||
let createdWindowIds = mainWindowIds().subtracting(existingWindowIds)
|
||||
for windowId in createdWindowIds {
|
||||
closeWindow(withId: windowId)
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
@ -422,6 +529,48 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
XCTAssertNil(self.window(withId: windowId), "Confirming Cmd+Ctrl+W should close the window")
|
||||
}
|
||||
|
||||
func testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let targetWindow = window(withId: windowId),
|
||||
let manager = appDelegate.tabManagerFor(windowId: windowId) else {
|
||||
XCTFail("Expected test window and manager")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.tabs.count, 1)
|
||||
XCTAssertEqual(manager.tabs[0].panels.count, 1)
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "w",
|
||||
modifiers: [.command],
|
||||
keyCode: 13,
|
||||
windowNumber: targetWindow.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+W event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertNil(
|
||||
self.window(withId: windowId),
|
||||
"Cmd+W on the last surface in the last workspace should close the window"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
@ -2336,6 +2485,16 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })
|
||||
}
|
||||
|
||||
private func mainWindowIds() -> Set<UUID> {
|
||||
Set(NSApp.windows.compactMap { window in
|
||||
guard let raw = window.identifier?.rawValue,
|
||||
raw.hasPrefix("cmux.main.") else {
|
||||
return nil
|
||||
}
|
||||
return UUID(uuidString: String(raw.dropFirst("cmux.main.".count)))
|
||||
})
|
||||
}
|
||||
|
||||
private func closeWindow(withId windowId: UUID) {
|
||||
guard let window = window(withId: windowId) else { return }
|
||||
window.performClose(nil)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import XCTest
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import WebKit
|
||||
import SwiftUI
|
||||
import ObjectiveC.runtime
|
||||
|
|
@ -56,6 +57,14 @@ private func installCmuxUnitTestInspectorOverride() {
|
|||
cmuxUnitTestInspectorOverrideInstalled = true
|
||||
}
|
||||
|
||||
private func drainMainQueue() {
|
||||
let expectation = XCTestExpectation(description: "drain main queue")
|
||||
DispatchQueue.main.async {
|
||||
expectation.fulfill()
|
||||
}
|
||||
XCTWaiter().wait(for: [expectation], timeout: 1.0)
|
||||
}
|
||||
|
||||
final class SplitShortcutTransientFocusGuardTests: XCTestCase {
|
||||
func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() {
|
||||
XCTAssertTrue(
|
||||
|
|
@ -864,6 +873,163 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class GhosttyPasteboardHelperTests: XCTestCase {
|
||||
func testHTMLOnlyPasteboardExtractsPlainText() {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString("<p>Hello <strong>world</strong></p>", forType: .html)
|
||||
|
||||
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world")
|
||||
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
}
|
||||
|
||||
func testImageHTMLClipboardFallsBackToImagePath() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString("<meta charset='utf-8'><img src=\"https://example.com/keyboard.png\">", forType: .html)
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.red.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
let tiffData = try XCTUnwrap(image.tiffRepresentation)
|
||||
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
|
||||
let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
|
||||
pasteboard.setData(pngData, forType: .png)
|
||||
|
||||
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
|
||||
|
||||
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
defer { try? FileManager.default.removeItem(atPath: imagePath) }
|
||||
|
||||
XCTAssertTrue(imagePath.hasSuffix(".png"))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
|
||||
}
|
||||
|
||||
func testImageHTMLClipboardWithVisibleTextPrefersText() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString("<p>Hello <img src=\"https://example.com/keyboard.png\"></p>", forType: .html)
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.blue.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
let tiffData = try XCTUnwrap(image.tiffRepresentation)
|
||||
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
|
||||
let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
|
||||
pasteboard.setData(pngData, forType: .png)
|
||||
|
||||
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
|
||||
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
}
|
||||
|
||||
func testJPEGClipboardFallsBackToImagePath() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.green.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
|
||||
let tiffData = try XCTUnwrap(image.tiffRepresentation)
|
||||
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
|
||||
let jpegData = try XCTUnwrap(
|
||||
bitmap.representation(
|
||||
using: .jpeg,
|
||||
properties: [.compressionFactor: 1.0]
|
||||
)
|
||||
)
|
||||
pasteboard.setData(
|
||||
jpegData,
|
||||
forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier)
|
||||
)
|
||||
|
||||
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
defer { try? FileManager.default.removeItem(atPath: imagePath) }
|
||||
|
||||
XCTAssertTrue(imagePath.hasSuffix(".jpeg"))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
|
||||
}
|
||||
|
||||
func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.orange.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = image
|
||||
let attributed = NSAttributedString(attachment: attachment)
|
||||
let data = try attributed.data(
|
||||
from: NSRange(location: 0, length: attributed.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
pasteboard.setData(data, forType: .rtfd)
|
||||
|
||||
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
|
||||
|
||||
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
defer { try? FileManager.default.removeItem(atPath: imagePath) }
|
||||
|
||||
XCTAssertTrue(imagePath.hasSuffix(".tiff"))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
|
||||
}
|
||||
|
||||
func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
|
||||
let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8))
|
||||
wrapper.preferredFilename = "note.txt"
|
||||
|
||||
let attachment = NSTextAttachment(fileWrapper: wrapper)
|
||||
let attributed = NSAttributedString(attachment: attachment)
|
||||
let data = try attributed.data(
|
||||
from: NSRange(location: 0, length: attributed.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
pasteboard.setData(data, forType: .rtfd)
|
||||
|
||||
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
|
||||
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
}
|
||||
|
||||
func testRTFDClipboardWithVisibleTextPrefersText() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.purple.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = image
|
||||
|
||||
let attributed = NSMutableAttributedString(string: "Hello ")
|
||||
attributed.append(NSAttributedString(attachment: attachment))
|
||||
let data = try attributed.data(
|
||||
from: NSRange(location: 0, length: attributed.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
pasteboard.setData(data, forType: .rtfd)
|
||||
|
||||
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
|
||||
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegateWindowContextRoutingTests: XCTestCase {
|
||||
private func makeMainWindow(id: UUID) -> NSWindow {
|
||||
|
|
@ -5063,6 +5229,54 @@ final class WorkspaceTeardownTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceSplitWorkingDirectoryTests: XCTestCase {
|
||||
func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() {
|
||||
let workspace = Workspace()
|
||||
guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else {
|
||||
XCTFail("Expected focused pane in new workspace")
|
||||
return
|
||||
}
|
||||
|
||||
let staleCurrentDirectory = workspace.currentDirectory
|
||||
let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)"
|
||||
guard let sourcePanel = workspace.newTerminalSurface(
|
||||
inPane: sourcePaneId,
|
||||
focus: false,
|
||||
workingDirectory: requestedDirectory
|
||||
) else {
|
||||
XCTFail("Expected source terminal panel to be created")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory)
|
||||
XCTAssertNil(
|
||||
workspace.panelDirectories[sourcePanel.id],
|
||||
"Expected requested cwd to exist before shell integration reports a live cwd"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
workspace.currentDirectory,
|
||||
staleCurrentDirectory,
|
||||
"Expected focused workspace cwd to remain stale before panel directory updates"
|
||||
)
|
||||
|
||||
guard let splitPanel = workspace.newTerminalSplit(
|
||||
from: sourcePanel.id,
|
||||
orientation: .horizontal,
|
||||
focus: false
|
||||
) else {
|
||||
XCTFail("Expected split terminal panel to be created")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
splitPanel.requestedWorkingDirectory,
|
||||
requestedDirectory,
|
||||
"Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerWorkspaceOwnershipTests: XCTestCase {
|
||||
func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() {
|
||||
|
|
@ -5084,6 +5298,297 @@ final class TabManagerWorkspaceOwnershipTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase {
|
||||
func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() {
|
||||
let manager = TabManager()
|
||||
let second = manager.addWorkspace()
|
||||
let third = manager.addWorkspace()
|
||||
manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
|
||||
manager.setCustomTitle(tabId: second.id, title: "Beta")
|
||||
manager.setCustomTitle(tabId: third.id, title: "Gamma")
|
||||
|
||||
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
|
||||
manager.confirmCloseHandler = { title, message, acceptCmdD in
|
||||
prompts.append((title, message, acceptCmdD))
|
||||
return true
|
||||
}
|
||||
|
||||
manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
|
||||
|
||||
let expectedMessage = String(
|
||||
format: String(
|
||||
localized: "dialog.closeWorkspaces.message",
|
||||
defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
|
||||
),
|
||||
locale: .current,
|
||||
Int64(2),
|
||||
"• Alpha\n• Beta"
|
||||
)
|
||||
XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close")
|
||||
XCTAssertEqual(
|
||||
prompts.first?.title,
|
||||
String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
|
||||
)
|
||||
XCTAssertEqual(prompts.first?.message, expectedMessage)
|
||||
XCTAssertEqual(prompts.first?.acceptCmdD, false)
|
||||
XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"])
|
||||
}
|
||||
|
||||
func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() {
|
||||
let manager = TabManager()
|
||||
let second = manager.addWorkspace()
|
||||
manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
|
||||
manager.setCustomTitle(tabId: second.id, title: "Beta")
|
||||
|
||||
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
|
||||
manager.confirmCloseHandler = { title, message, acceptCmdD in
|
||||
prompts.append((title, message, acceptCmdD))
|
||||
return false
|
||||
}
|
||||
|
||||
manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
|
||||
|
||||
let expectedMessage = String(
|
||||
format: String(
|
||||
localized: "dialog.closeWorkspacesWindow.message",
|
||||
defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
|
||||
),
|
||||
locale: .current,
|
||||
Int64(2),
|
||||
"• Alpha\n• Beta"
|
||||
)
|
||||
XCTAssertEqual(prompts.count, 1)
|
||||
XCTAssertEqual(
|
||||
prompts.first?.title,
|
||||
String(localized: "dialog.closeWindow.title", defaultValue: "Close window?")
|
||||
)
|
||||
XCTAssertEqual(prompts.first?.message, expectedMessage)
|
||||
XCTAssertEqual(prompts.first?.acceptCmdD, true)
|
||||
XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"])
|
||||
}
|
||||
|
||||
func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() {
|
||||
let manager = TabManager()
|
||||
let second = manager.addWorkspace()
|
||||
let third = manager.addWorkspace()
|
||||
manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
|
||||
manager.setCustomTitle(tabId: second.id, title: "Beta")
|
||||
manager.setCustomTitle(tabId: third.id, title: "Gamma")
|
||||
manager.selectWorkspace(second)
|
||||
manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id])
|
||||
|
||||
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
|
||||
manager.confirmCloseHandler = { title, message, acceptCmdD in
|
||||
prompts.append((title, message, acceptCmdD))
|
||||
return false
|
||||
}
|
||||
|
||||
manager.closeCurrentWorkspaceWithConfirmation()
|
||||
|
||||
let expectedMessage = String(
|
||||
format: String(
|
||||
localized: "dialog.closeWorkspaces.message",
|
||||
defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
|
||||
),
|
||||
locale: .current,
|
||||
Int64(2),
|
||||
"• Alpha\n• Beta"
|
||||
)
|
||||
XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog")
|
||||
XCTAssertEqual(
|
||||
prompts.first?.title,
|
||||
String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
|
||||
)
|
||||
XCTAssertEqual(prompts.first?.message, expectedMessage)
|
||||
XCTAssertEqual(prompts.first?.acceptCmdD, false)
|
||||
XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerCloseCurrentPanelTests: XCTestCase {
|
||||
func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let panelId = workspace.focusedPanelId,
|
||||
let terminalPanel = workspace.terminalPanel(for: panelId) else {
|
||||
XCTFail("Expected selected workspace and focused terminal panel")
|
||||
return
|
||||
}
|
||||
|
||||
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true)
|
||||
workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle)
|
||||
|
||||
var promptCount = 0
|
||||
manager.confirmCloseHandler = { _, _, _ in
|
||||
promptCount += 1
|
||||
return false
|
||||
}
|
||||
|
||||
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state")
|
||||
XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close")
|
||||
XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel")
|
||||
}
|
||||
|
||||
func testRuntimeClosePromptsWhenShellReportsRunningCommand() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let panelId = workspace.focusedPanelId,
|
||||
let terminalPanel = workspace.terminalPanel(for: panelId) else {
|
||||
XCTFail("Expected selected workspace and focused terminal panel")
|
||||
return
|
||||
}
|
||||
|
||||
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false)
|
||||
workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning)
|
||||
|
||||
var promptCount = 0
|
||||
manager.confirmCloseHandler = { _, _, _ in
|
||||
promptCount += 1
|
||||
return false
|
||||
}
|
||||
|
||||
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
|
||||
|
||||
XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation")
|
||||
XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open")
|
||||
}
|
||||
|
||||
func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() {
|
||||
let manager = TabManager()
|
||||
let firstWorkspace = manager.tabs[0]
|
||||
let secondWorkspace = manager.addWorkspace()
|
||||
manager.selectWorkspace(secondWorkspace)
|
||||
|
||||
guard let secondPanelId = secondWorkspace.focusedPanelId else {
|
||||
XCTFail("Expected focused panel in selected workspace")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
|
||||
XCTAssertEqual(secondWorkspace.panels.count, 1)
|
||||
|
||||
manager.closeCurrentPanelWithConfirmation()
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
||||
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
||||
XCTAssertNil(secondWorkspace.panels[secondPanelId])
|
||||
XCTAssertTrue(secondWorkspace.panels.isEmpty)
|
||||
}
|
||||
|
||||
func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() {
|
||||
let manager = TabManager()
|
||||
let firstWorkspace = manager.tabs[0]
|
||||
let secondWorkspace = manager.addWorkspace()
|
||||
manager.selectWorkspace(secondWorkspace)
|
||||
|
||||
guard let secondPanelId = secondWorkspace.focusedPanelId else {
|
||||
XCTFail("Expected focused panel in selected workspace")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
|
||||
XCTAssertEqual(secondWorkspace.panels.count, 1)
|
||||
|
||||
guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
|
||||
XCTFail("Expected bonsplit surface ID for focused panel")
|
||||
return
|
||||
}
|
||||
|
||||
secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
|
||||
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
||||
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
||||
XCTAssertNil(secondWorkspace.panels[secondPanelId])
|
||||
XCTAssertTrue(secondWorkspace.panels.isEmpty)
|
||||
}
|
||||
|
||||
func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let initialPanelId = workspace.focusedPanelId else {
|
||||
XCTFail("Expected selected workspace and focused panel")
|
||||
return
|
||||
}
|
||||
|
||||
let initialWorkspaceId = workspace.id
|
||||
XCTAssertEqual(manager.tabs.count, 1)
|
||||
XCTAssertEqual(workspace.panels.count, 1)
|
||||
|
||||
XCTAssertTrue(workspace.closePanel(initialPanelId))
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(manager.tabs.count, 1)
|
||||
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
|
||||
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
|
||||
XCTAssertNil(workspace.panels[initialPanelId])
|
||||
XCTAssertEqual(workspace.panels.count, 1)
|
||||
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
|
||||
}
|
||||
|
||||
func testCloseCurrentPanelIgnoresStaleSurfaceId() {
|
||||
let manager = TabManager()
|
||||
let firstWorkspace = manager.tabs[0]
|
||||
let secondWorkspace = manager.addWorkspace()
|
||||
|
||||
manager.closePanelWithConfirmation(tabId: secondWorkspace.id, surfaceId: UUID())
|
||||
|
||||
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id, secondWorkspace.id])
|
||||
}
|
||||
|
||||
func testCloseCurrentPanelClearsNotificationsForClosedSurface() {
|
||||
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
||||
let manager = TabManager()
|
||||
let store = TerminalNotificationStore.shared
|
||||
|
||||
let originalTabManager = appDelegate.tabManager
|
||||
let originalNotificationStore = appDelegate.notificationStore
|
||||
store.replaceNotificationsForTesting([])
|
||||
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
||||
appDelegate.tabManager = manager
|
||||
appDelegate.notificationStore = store
|
||||
|
||||
defer {
|
||||
store.replaceNotificationsForTesting([])
|
||||
store.resetNotificationDeliveryHandlerForTesting()
|
||||
appDelegate.tabManager = originalTabManager
|
||||
appDelegate.notificationStore = originalNotificationStore
|
||||
}
|
||||
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let initialPanelId = workspace.focusedPanelId else {
|
||||
XCTFail("Expected selected workspace and focused panel")
|
||||
return
|
||||
}
|
||||
|
||||
store.addNotification(
|
||||
tabId: workspace.id,
|
||||
surfaceId: initialPanelId,
|
||||
title: "Unread",
|
||||
subtitle: "",
|
||||
body: ""
|
||||
)
|
||||
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
|
||||
|
||||
manager.closeCurrentPanelWithConfirmation()
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerPendingUnfocusPolicyTests: XCTestCase {
|
||||
func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {
|
||||
|
|
@ -7793,6 +8298,44 @@ final class NotificationDockBadgeTests: XCTestCase {
|
|||
XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testNotificationPaneFlashPreferenceDefaultsToEnabled() {
|
||||
let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
|
||||
|
||||
defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey)
|
||||
XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
|
||||
|
||||
defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey)
|
||||
XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testMenuBarExtraPreferenceDefaultsToVisible() {
|
||||
let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
|
||||
|
||||
defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey)
|
||||
XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
|
||||
|
||||
defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey)
|
||||
XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
|
||||
}
|
||||
|
||||
func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() {
|
||||
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
|
|
@ -10480,6 +11023,27 @@ final class InternalTabDragConfigurationTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class InternalTabDragBundleDeclarationTests: XCTestCase {
|
||||
private func exportedTypeIdentifiers(bundle: Bundle) -> Set<String> {
|
||||
let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? []
|
||||
return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String })
|
||||
}
|
||||
|
||||
func testAppBundleExportsInternalDragTypes() {
|
||||
let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self))
|
||||
|
||||
XCTAssertTrue(
|
||||
exported.contains("com.splittabbar.tabtransfer"),
|
||||
"Expected app bundle to export bonsplit tab-transfer type, got \(exported)"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
exported.contains("com.cmux.sidebar-tab-reorder"),
|
||||
"Expected app bundle to export sidebar tab-reorder type, got \(exported)"
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
|
|
|
|||
282
cmuxTests/WorkspaceStressProfileTests.swift
Normal file
282
cmuxTests/WorkspaceStressProfileTests.swift
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceStressProfileTests: XCTestCase {
|
||||
private struct StressConfig {
|
||||
let workspaceCount: Int
|
||||
let tabsPerWorkspace: Int
|
||||
let switchPasses: Int
|
||||
let createP95BudgetMs: Double?
|
||||
let switchP95BudgetMs: Double?
|
||||
|
||||
static func current(environment: [String: String] = ProcessInfo.processInfo.environment) -> StressConfig {
|
||||
StressConfig(
|
||||
workspaceCount: parseInt(environment["CMUX_WORKSPACE_STRESS_WORKSPACES"], default: 48, minimum: 2),
|
||||
tabsPerWorkspace: parseInt(environment["CMUX_WORKSPACE_STRESS_TABS_PER_WORKSPACE"], default: 10, minimum: 1),
|
||||
switchPasses: parseInt(environment["CMUX_WORKSPACE_STRESS_SWITCH_PASSES"], default: 6, minimum: 1),
|
||||
createP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_CREATE_P95_BUDGET_MS"]),
|
||||
switchP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_SWITCH_P95_BUDGET_MS"])
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseInt(_ value: String?, default defaultValue: Int, minimum: Int) -> Int {
|
||||
guard let value, let parsed = Int(value) else { return defaultValue }
|
||||
return max(minimum, parsed)
|
||||
}
|
||||
|
||||
private static func parseDouble(_ value: String?) -> Double? {
|
||||
guard let value, let parsed = Double(value) else { return nil }
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimedSample {
|
||||
let label: String
|
||||
let elapsedMs: Double
|
||||
}
|
||||
|
||||
private struct TimingSummary {
|
||||
let count: Int
|
||||
let averageMs: Double
|
||||
let medianMs: Double
|
||||
let p95Ms: Double
|
||||
let maxMs: Double
|
||||
let totalMs: Double
|
||||
|
||||
init(samples: [TimedSample]) {
|
||||
let sorted = samples.map(\.elapsedMs).sorted()
|
||||
count = sorted.count
|
||||
totalMs = sorted.reduce(0, +)
|
||||
averageMs = count > 0 ? totalMs / Double(count) : 0
|
||||
medianMs = Self.percentile(0.50, in: sorted)
|
||||
p95Ms = Self.percentile(0.95, in: sorted)
|
||||
maxMs = sorted.last ?? 0
|
||||
}
|
||||
|
||||
private static func percentile(_ percentile: Double, in sortedValues: [Double]) -> Double {
|
||||
guard !sortedValues.isEmpty else { return 0 }
|
||||
let clamped = min(max(percentile, 0), 1)
|
||||
let index = Int((Double(sortedValues.count - 1) * clamped).rounded(.up))
|
||||
return sortedValues[min(sortedValues.count - 1, max(0, index))]
|
||||
}
|
||||
}
|
||||
|
||||
func testWorkspaceCreationAndSwitchingStressProfile() {
|
||||
let config = StressConfig.current()
|
||||
let welcomeWasShown = UserDefaults.standard.object(forKey: WelcomeSettings.shownKey)
|
||||
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
||||
defer {
|
||||
if let welcomeWasShown {
|
||||
UserDefaults.standard.set(welcomeWasShown, forKey: WelcomeSettings.shownKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: WelcomeSettings.shownKey)
|
||||
}
|
||||
}
|
||||
|
||||
var creationSamples: [TimedSample] = []
|
||||
var populationSamples: [TimedSample] = []
|
||||
var switchSamples: [TimedSample] = []
|
||||
var switchDispatchSamples: [TimedSample] = []
|
||||
var switchFirstDrainSamples: [TimedSample] = []
|
||||
var switchUnfocusSamples: [TimedSample] = []
|
||||
var switchSecondDrainSamples: [TimedSample] = []
|
||||
|
||||
let manager = timed("workspace-000-create", collectInto: &creationSamples) {
|
||||
TabManager()
|
||||
}
|
||||
|
||||
guard let bootstrapWorkspace = manager.selectedWorkspace else {
|
||||
XCTFail("Expected bootstrap workspace")
|
||||
return
|
||||
}
|
||||
|
||||
timed("workspace-000-populate", collectInto: &populationSamples) {
|
||||
populate(workspace: bootstrapWorkspace, tabsPerWorkspace: config.tabsPerWorkspace)
|
||||
}
|
||||
settleWorkspaceSelection(manager)
|
||||
|
||||
for workspaceIndex in 1..<config.workspaceCount {
|
||||
let workspace = timed("workspace-\(label(for: workspaceIndex))-create", collectInto: &creationSamples) {
|
||||
manager.addWorkspace(
|
||||
select: true,
|
||||
eagerLoadTerminal: false,
|
||||
autoWelcomeIfNeeded: false
|
||||
)
|
||||
}
|
||||
|
||||
settleWorkspaceSelection(manager)
|
||||
|
||||
timed("workspace-\(label(for: workspaceIndex))-populate", collectInto: &populationSamples) {
|
||||
populate(workspace: workspace, tabsPerWorkspace: config.tabsPerWorkspace)
|
||||
}
|
||||
settleWorkspaceSelection(manager)
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.tabs.count, config.workspaceCount)
|
||||
XCTAssertTrue(manager.tabs.allSatisfy { $0.panels.count == config.tabsPerWorkspace })
|
||||
|
||||
for pass in 0..<config.switchPasses {
|
||||
for switchIndex in 0..<manager.tabs.count {
|
||||
timed("pass-\(label(for: pass))-next-\(label(for: switchIndex))", collectInto: &switchSamples) {
|
||||
timed("pass-\(label(for: pass))-next-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
|
||||
manager.selectNextTab()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-next-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-next-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
|
||||
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
|
||||
}
|
||||
timed("pass-\(label(for: pass))-next-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for switchIndex in 0..<manager.tabs.count {
|
||||
timed("pass-\(label(for: pass))-prev-\(label(for: switchIndex))", collectInto: &switchSamples) {
|
||||
timed("pass-\(label(for: pass))-prev-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
|
||||
manager.selectPreviousTab()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-prev-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-prev-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
|
||||
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
|
||||
}
|
||||
timed("pass-\(label(for: pass))-prev-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertNotNil(manager.selectedWorkspace)
|
||||
|
||||
let creationSummary = TimingSummary(samples: creationSamples)
|
||||
let populationSummary = TimingSummary(samples: populationSamples)
|
||||
let switchSummary = TimingSummary(samples: switchSamples)
|
||||
let switchDispatchSummary = TimingSummary(samples: switchDispatchSamples)
|
||||
let switchFirstDrainSummary = TimingSummary(samples: switchFirstDrainSamples)
|
||||
let switchUnfocusSummary = TimingSummary(samples: switchUnfocusSamples)
|
||||
let switchSecondDrainSummary = TimingSummary(samples: switchSecondDrainSamples)
|
||||
|
||||
let report = [
|
||||
"Workspace stress config workspaces=\(config.workspaceCount) tabsPerWorkspace=\(config.tabsPerWorkspace) switchPasses=\(config.switchPasses)",
|
||||
reportLine(title: "create", summary: creationSummary, slowest: slowest(creationSamples)),
|
||||
reportLine(title: "populate", summary: populationSummary, slowest: slowest(populationSamples)),
|
||||
reportLine(title: "switch", summary: switchSummary, slowest: slowest(switchSamples)),
|
||||
reportLine(title: "switch.dispatch", summary: switchDispatchSummary, slowest: slowest(switchDispatchSamples)),
|
||||
reportLine(title: "switch.drain1", summary: switchFirstDrainSummary, slowest: slowest(switchFirstDrainSamples)),
|
||||
reportLine(title: "switch.unfocus", summary: switchUnfocusSummary, slowest: slowest(switchUnfocusSamples)),
|
||||
reportLine(title: "switch.drain2", summary: switchSecondDrainSummary, slowest: slowest(switchSecondDrainSamples))
|
||||
].joined(separator: "\n")
|
||||
|
||||
print(report)
|
||||
let attachment = XCTAttachment(string: report)
|
||||
attachment.name = "workspace-stress-profile"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
|
||||
if let createP95BudgetMs = config.createP95BudgetMs {
|
||||
XCTAssertLessThanOrEqual(
|
||||
creationSummary.p95Ms,
|
||||
createP95BudgetMs,
|
||||
"Workspace creation p95 exceeded budget"
|
||||
)
|
||||
}
|
||||
if let switchP95BudgetMs = config.switchP95BudgetMs {
|
||||
XCTAssertLessThanOrEqual(
|
||||
switchSummary.p95Ms,
|
||||
switchP95BudgetMs,
|
||||
"Workspace switch p95 exceeded budget"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func populate(workspace: Workspace, tabsPerWorkspace: Int) {
|
||||
guard tabsPerWorkspace > 0 else { return }
|
||||
while workspace.panels.count < tabsPerWorkspace {
|
||||
let created = workspace.newTerminalSurfaceInFocusedPane(focus: false)
|
||||
guard created != nil else {
|
||||
XCTFail("Expected terminal tab creation to succeed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func settleWorkspaceSelection(_ manager: TabManager) {
|
||||
drainMainQueue()
|
||||
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
|
||||
drainMainQueue()
|
||||
}
|
||||
|
||||
private func drainMainQueue() {
|
||||
let deadline = Date(timeIntervalSinceNow: 1.0)
|
||||
var drained = false
|
||||
DispatchQueue.main.async {
|
||||
drained = true
|
||||
}
|
||||
while !drained {
|
||||
if Date() >= deadline {
|
||||
XCTFail("Timed out draining main queue")
|
||||
return
|
||||
}
|
||||
let sliceDeadline = min(deadline, Date(timeIntervalSinceNow: 0.001))
|
||||
_ = RunLoop.main.run(mode: .default, before: sliceDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func timed<T>(
|
||||
_ label: String,
|
||||
collectInto samples: inout [TimedSample],
|
||||
operation: () -> T
|
||||
) -> T {
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
let value = operation()
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
|
||||
samples.append(TimedSample(label: label, elapsedMs: elapsedMs))
|
||||
return value
|
||||
}
|
||||
|
||||
private func slowest(_ samples: [TimedSample], count: Int = 5) -> String {
|
||||
samples
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.elapsedMs == rhs.elapsedMs {
|
||||
return lhs.label < rhs.label
|
||||
}
|
||||
return lhs.elapsedMs > rhs.elapsedMs
|
||||
}
|
||||
.prefix(count)
|
||||
.map { "\($0.label)=\(formatMs($0.elapsedMs))" }
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func reportLine(title: String, summary: TimingSummary, slowest: String) -> String {
|
||||
[
|
||||
"\(title):",
|
||||
"count=\(summary.count)",
|
||||
"avg=\(formatMs(summary.averageMs))",
|
||||
"median=\(formatMs(summary.medianMs))",
|
||||
"p95=\(formatMs(summary.p95Ms))",
|
||||
"max=\(formatMs(summary.maxMs))",
|
||||
"total=\(formatMs(summary.totalMs))",
|
||||
"slowest=[\(slowest)]"
|
||||
].joined(separator: " ")
|
||||
}
|
||||
|
||||
private func formatMs(_ value: Double) -> String {
|
||||
String(format: "%.2fms", value)
|
||||
}
|
||||
|
||||
private func label(for index: Int) -> String {
|
||||
String(format: "%03d", index)
|
||||
}
|
||||
}
|
||||
|
|
@ -27,23 +27,32 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testCmdDConfirmsCloseWhenClosingLastTabClosesWindow() {
|
||||
func testCmdWClosingLastTabKeepsWorkspaceWindowOpen() {
|
||||
let app = XCUIApplication()
|
||||
// Closing the last tab should also present a confirmation and accept Cmd+D when it would close the window.
|
||||
app.launchEnvironment["CMUX_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1"
|
||||
let keyequivPath = "/tmp/cmux-ui-test-keyequiv-\(UUID().uuidString).json"
|
||||
try? FileManager.default.removeItem(atPath: keyequivPath)
|
||||
app.launchEnvironment["CMUX_UI_TEST_KEYEQUIV_PATH"] = keyequivPath
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
// Close current tab (Cmd+W). With a single workspace and a single tab, this will close the window after confirmation.
|
||||
let baseline = loadJSON(atPath: keyequivPath)?["closePanelInvocations"].flatMap(Int.init) ?? 0
|
||||
app.typeKey("w", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForCloseTabAlert(app: app, timeout: 5.0))
|
||||
XCTAssertTrue(
|
||||
waitForKeyequivInt("closePanelInvocations", toBeAtLeast: baseline + 1, atPath: keyequivPath, timeout: 5.0),
|
||||
"Expected Cmd+W to route through the close-current-tab action"
|
||||
)
|
||||
|
||||
// Cmd+D should accept the destructive close and close the window.
|
||||
app.typeKey("d", modifierFlags: [.command])
|
||||
if waitForCloseTabAlert(app: app, timeout: 5.0) {
|
||||
clickCloseOnCloseTabAlert(app: app)
|
||||
XCTAssertFalse(
|
||||
isCloseTabAlertPresent(app: app),
|
||||
"Expected close tab confirmation to dismiss after confirming the close"
|
||||
)
|
||||
}
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForNoWindowsOrAppNotRunningForeground(app: app, timeout: 6.0),
|
||||
"Expected Cmd+D to confirm close and close the last window"
|
||||
waitForWindowCount(app: app, atLeast: 1, timeout: 6.0),
|
||||
"Expected Cmd+W on the last tab to keep the workspace window open"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -608,12 +617,37 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
private func waitForCloseTabAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.dialogs.containing(.staticText, identifier: "Close tab?").firstMatch.exists { return true }
|
||||
if app.alerts.containing(.staticText, identifier: "Close tab?").firstMatch.exists { return true }
|
||||
if app.staticTexts["Close tab?"].exists { return true }
|
||||
if isCloseTabAlertPresent(app: app) { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return false
|
||||
return isCloseTabAlertPresent(app: app)
|
||||
}
|
||||
|
||||
// Must match the defaultValue for dialog.closeTab.title in TabManager.
|
||||
private func isCloseTabAlertPresent(app: XCUIApplication) -> Bool {
|
||||
if app.dialogs.containing(.staticText, identifier: "Close tab?").firstMatch.exists { return true }
|
||||
if app.alerts.containing(.staticText, identifier: "Close tab?").firstMatch.exists { return true }
|
||||
return app.staticTexts["Close tab?"].exists
|
||||
}
|
||||
|
||||
// Must match the defaultValue for dialog.closeTab.title in TabManager.
|
||||
private func clickCloseOnCloseTabAlert(app: XCUIApplication) {
|
||||
let dialog = app.dialogs.containing(.staticText, identifier: "Close tab?").firstMatch
|
||||
if dialog.exists {
|
||||
dialog.buttons["Close"].firstMatch.click()
|
||||
return
|
||||
}
|
||||
|
||||
let alert = app.alerts.containing(.staticText, identifier: "Close tab?").firstMatch
|
||||
if alert.exists {
|
||||
alert.buttons["Close"].firstMatch.click()
|
||||
return
|
||||
}
|
||||
|
||||
let anyDialog = app.dialogs.firstMatch
|
||||
if anyDialog.exists, anyDialog.buttons["Close"].exists {
|
||||
anyDialog.buttons["Close"].firstMatch.click()
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForWindowCount(app: XCUIApplication, toBe count: Int, timeout: TimeInterval) -> Bool {
|
||||
|
|
@ -644,6 +678,17 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
return app.state != .runningForeground || app.windows.count == 0
|
||||
}
|
||||
|
||||
private func waitForKeyequivInt(_ key: String, toBeAtLeast expected: Int, atPath path: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
|
||||
if value >= expected { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
|
||||
return value >= expected
|
||||
}
|
||||
|
||||
private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
|
|
|
|||
219
cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift
Normal file
219
cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
|
||||
final class CloseWorkspacesConfirmDialogUITests: XCTestCase {
|
||||
private var socketPath = ""
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
socketPath = "/tmp/cmux-ui-test-close-workspaces-\(UUID().uuidString).sock"
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
}
|
||||
|
||||
func testCommandPaletteCloseOtherWorkspacesShowsSingleSummaryDialog() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1"
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch for close-workspaces confirmation test. state=\(app.state.rawValue)"
|
||||
)
|
||||
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)")
|
||||
|
||||
XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK")
|
||||
XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK")
|
||||
XCTAssertTrue(
|
||||
waitForWorkspaceCount(3, timeout: 5.0),
|
||||
"Expected 3 workspaces before running the close-other-workspaces command. list=\(socketCommand("list_workspaces") ?? "<nil>")"
|
||||
)
|
||||
XCTAssertEqual(socketCommand("select_workspace 1"), "OK")
|
||||
|
||||
app.typeKey("p", modifierFlags: [.command, .shift])
|
||||
|
||||
let searchField = app.textFields["CommandPaletteSearchField"]
|
||||
XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
|
||||
searchField.click()
|
||||
searchField.typeText("Close Other Workspaces")
|
||||
|
||||
let resultButton = app.buttons["Close Other Workspaces"].firstMatch
|
||||
if resultButton.waitForExistence(timeout: 5.0) {
|
||||
resultButton.click()
|
||||
} else {
|
||||
app.typeKey(.return, modifierFlags: [])
|
||||
}
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForCloseWorkspacesAlert(app: app, timeout: 5.0),
|
||||
"Expected a single aggregated close-workspaces alert"
|
||||
)
|
||||
|
||||
clickCancelOnCloseWorkspacesAlert(app: app)
|
||||
|
||||
XCTAssertFalse(
|
||||
isCloseWorkspacesAlertPresent(app: app),
|
||||
"Expected aggregated close-workspaces alert to dismiss after clicking Cancel"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
waitForWorkspaceCount(3, timeout: 5.0),
|
||||
"Expected all workspaces to remain after cancelling multi-close. list=\(socketCommand("list_workspaces") ?? "<nil>")"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdShiftWUsesSidebarMultiSelectionSummaryDialog() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_SIDEBAR_SELECTED_WORKSPACE_INDICES"] = "0,1"
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch for close-workspaces shortcut test. state=\(app.state.rawValue)"
|
||||
)
|
||||
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)")
|
||||
|
||||
XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK")
|
||||
XCTAssertTrue(
|
||||
waitForWorkspaceCount(2, timeout: 5.0),
|
||||
"Expected 2 workspaces before running Cmd+Shift+W. list=\(socketCommand("list_workspaces") ?? "<nil>")"
|
||||
)
|
||||
|
||||
app.typeKey("w", modifierFlags: [.command, .shift])
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForCloseWorkspacesAlert(app: app, timeout: 5.0),
|
||||
"Expected Cmd+Shift+W to use the aggregated close-workspaces alert for sidebar multi-selection"
|
||||
)
|
||||
|
||||
clickCancelOnCloseWorkspacesAlert(app: app)
|
||||
|
||||
XCTAssertFalse(
|
||||
isCloseWorkspacesAlertPresent(app: app),
|
||||
"Expected aggregated close-workspaces alert to dismiss after clicking Cancel"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
waitForWorkspaceCount(2, timeout: 5.0),
|
||||
"Expected both workspaces to remain after cancelling Cmd+Shift+W multi-close. list=\(socketCommand("list_workspaces") ?? "<nil>")"
|
||||
)
|
||||
}
|
||||
|
||||
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||
return true
|
||||
}
|
||||
if app.state == .runningBackground {
|
||||
app.activate()
|
||||
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if socketCommand("ping") == "PONG" {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return socketCommand("ping") == "PONG"
|
||||
}
|
||||
|
||||
private func waitForWorkspaceCount(_ expectedCount: Int, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if workspaceCount() == expectedCount {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return workspaceCount() == expectedCount
|
||||
}
|
||||
|
||||
private func workspaceCount() -> Int {
|
||||
guard let response = socketCommand("list_workspaces") else { return -1 }
|
||||
if response == "No workspaces" {
|
||||
return 0
|
||||
}
|
||||
return response
|
||||
.split(separator: "\n")
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.count
|
||||
}
|
||||
|
||||
private func socketCommand(_ cmd: String) -> String? {
|
||||
let nc = "/usr/bin/nc"
|
||||
guard FileManager.default.isExecutableFile(atPath: nc) else { return nil }
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: nc)
|
||||
proc.arguments = ["-U", socketPath, "-w", "2"]
|
||||
|
||||
let inPipe = Pipe()
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
proc.standardInput = inPipe
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = errPipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let data = (cmd + "\n").data(using: .utf8) {
|
||||
inPipe.fileHandleForWriting.write(data)
|
||||
}
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
proc.waitUntilExit()
|
||||
|
||||
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
guard let outStr = String(data: outData, encoding: .utf8) else { return nil }
|
||||
return outStr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func isCloseWorkspacesAlertPresent(app: XCUIApplication) -> Bool {
|
||||
if closeWorkspacesDialog(app: app).exists { return true }
|
||||
if closeWorkspacesAlert(app: app).exists { return true }
|
||||
return app.staticTexts["Close workspaces?"].exists
|
||||
}
|
||||
|
||||
private func waitForCloseWorkspacesAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if isCloseWorkspacesAlertPresent(app: app) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return isCloseWorkspacesAlertPresent(app: app)
|
||||
}
|
||||
|
||||
private func clickCancelOnCloseWorkspacesAlert(app: XCUIApplication) {
|
||||
let dialog = closeWorkspacesDialog(app: app)
|
||||
if dialog.exists {
|
||||
dialog.buttons["Cancel"].firstMatch.click()
|
||||
return
|
||||
}
|
||||
let alert = closeWorkspacesAlert(app: app)
|
||||
if alert.exists {
|
||||
alert.buttons["Cancel"].firstMatch.click()
|
||||
return
|
||||
}
|
||||
let anyDialog = app.dialogs.firstMatch
|
||||
if anyDialog.exists, anyDialog.buttons["Cancel"].exists {
|
||||
anyDialog.buttons["Cancel"].firstMatch.click()
|
||||
}
|
||||
}
|
||||
|
||||
private func closeWorkspacesDialog(app: XCUIApplication) -> XCUIElement {
|
||||
app.dialogs.containing(.staticText, identifier: "Close workspaces?").firstMatch
|
||||
}
|
||||
|
||||
private func closeWorkspacesAlert(app: XCUIApplication) -> XCUIElement {
|
||||
app.alerts.containing(.staticText, identifier: "Close workspaces?").firstMatch
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
|
|
@ -77,14 +77,16 @@ touch the same stale-frame mitigation path and tend to conflict in the same file
|
|||
- Commits:
|
||||
- `0cf559581` (zsh: fix Pure-style multiline prompt redraws)
|
||||
- `312c7b23a` (zsh: avoid extra Pure continuation markers)
|
||||
- `404a3f175` (Fix Pure prompt redraw markers)
|
||||
- Files:
|
||||
- `src/shell-integration/zsh/ghostty-integration`
|
||||
- Summary:
|
||||
- Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line.
|
||||
- Keeps redraw-safe prompt-start markers for async themes.
|
||||
- Avoids inserting an explicit continuation marker after Pure's hidden carriage return, because Ghostty already tracks the newline as prompt continuation and the extra marker duplicates the preprompt row.
|
||||
- Restores that prompt-marker behavior on top of the current Ghostty `main` base after the older redraw fix drifted out during later submodule updates.
|
||||
|
||||
The fork branch HEAD is now the section 6 zsh redraw commit.
|
||||
The fork branch HEAD is now the section 6 zsh redraw follow-up commit.
|
||||
|
||||
### 7) cmux theme picker helper hooks
|
||||
|
||||
|
|
|
|||
2
ghostty
2
ghostty
|
|
@ -1 +1 @@
|
|||
Subproject commit 80cca8a12ebd554953fc6b35235135a3e61fe20c
|
||||
Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42
|
||||
|
|
@ -5,3 +5,4 @@
|
|||
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
|
||||
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
|
||||
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30
|
||||
404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd
|
||||
|
|
|
|||
|
|
@ -59,24 +59,29 @@ def _wait_for_focused_cwd(
|
|||
client: cmux,
|
||||
expected: str,
|
||||
timeout: float = 12.0,
|
||||
exclude_panel: str | None = None,
|
||||
panel: str | None = None,
|
||||
tab: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Wait for focused_cwd to match expected.
|
||||
|
||||
If exclude_panel is given, also require that focused_panel differs from
|
||||
that value — ensuring we're checking the *new* pane, not the original.
|
||||
If panel is given, also require that focused_panel matches that panel.
|
||||
If tab is given, also require that the selected tab matches that tab.
|
||||
"""
|
||||
def pred():
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
cwd = state.get("focused_cwd", "")
|
||||
if cwd != expected:
|
||||
return None
|
||||
if exclude_panel and state.get("focused_panel", "") == exclude_panel:
|
||||
if panel and state.get("focused_panel", "") != panel:
|
||||
return None
|
||||
if tab and state.get("tab", "") != tab:
|
||||
return None
|
||||
return state
|
||||
label = f"focused_cwd={expected!r}"
|
||||
if exclude_panel:
|
||||
label += f" (panel != {exclude_panel})"
|
||||
if panel:
|
||||
label += f" (panel == {panel})"
|
||||
if tab:
|
||||
label += f" (tab == {tab})"
|
||||
return _wait_for(pred, timeout=timeout, interval=0.3, label=label)
|
||||
|
||||
|
||||
|
|
@ -84,12 +89,25 @@ def _send_cd_and_wait(
|
|||
client: cmux,
|
||||
target: str,
|
||||
timeout: float = 12.0,
|
||||
surface: str | int | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""cd to target and wait for sidebar focused_cwd to reflect it."""
|
||||
client.send(f"cd {target}\n")
|
||||
if surface is None:
|
||||
client.send(f"cd {target}\n")
|
||||
else:
|
||||
client.send_surface(surface, f"cd {target}\n")
|
||||
return _wait_for_focused_cwd(client, target, timeout=timeout)
|
||||
|
||||
|
||||
def _focus_first_surface(client: cmux) -> str:
|
||||
surfaces = client.list_surfaces()
|
||||
if not surfaces:
|
||||
raise AssertionError("Current tab has no surfaces")
|
||||
surface_id = surfaces[0][1]
|
||||
client.focus_surface(surface_id)
|
||||
return surface_id
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG", "")
|
||||
|
||||
|
|
@ -119,17 +137,22 @@ def main() -> int:
|
|||
|
||||
print("=== Split CWD Inheritance Tests ===")
|
||||
|
||||
print(" [setup] creating isolated workspace tab...")
|
||||
setup_tab = client.new_tab()
|
||||
client.select_tab(setup_tab)
|
||||
time.sleep(1.0)
|
||||
setup_surface = _focus_first_surface(client)
|
||||
time.sleep(0.5)
|
||||
|
||||
# --- Setup: cd to test_dir_a in workspace 1 ---
|
||||
print(" [setup] cd to test_dir_a and wait for shell integration...")
|
||||
_send_cd_and_wait(client, test_dir_a)
|
||||
_send_cd_and_wait(client, test_dir_a, surface=setup_surface)
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a,
|
||||
f"got {state.get('focused_cwd')!r}")
|
||||
|
||||
# --- Test 1: New split inherits test_dir_a ---
|
||||
print(" [test1] creating right split from test_dir_a...")
|
||||
# Record the original panel so we can verify focus moves to the NEW pane.
|
||||
original_panel = state.get("focused_panel", "")
|
||||
split_result = client.new_split("right")
|
||||
if not split_result:
|
||||
check("split created", False)
|
||||
|
|
@ -138,15 +161,15 @@ def main() -> int:
|
|||
return 1
|
||||
check("split created", True)
|
||||
|
||||
# Wait for the NEW pane (different panel ID) to report test_dir_a.
|
||||
# Socket split commands should not steal focus; focus the returned pane
|
||||
# explicitly, then assert that pane inherited the source cwd.
|
||||
new_panel = split_result.strip()
|
||||
client.focus_surface_by_panel(new_panel)
|
||||
time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND
|
||||
try:
|
||||
state = _wait_for_focused_cwd(
|
||||
client, test_dir_a, timeout=15.0, exclude_panel=original_panel,
|
||||
client, test_dir_a, timeout=15.0, panel=new_panel,
|
||||
)
|
||||
new_panel = state.get("focused_panel", "")
|
||||
check("test1: focus moved to new pane", new_panel != original_panel,
|
||||
f"original={original_panel!r}, current={new_panel!r}")
|
||||
check("test1: split inherited test_dir_a",
|
||||
state.get("focused_cwd") == test_dir_a,
|
||||
f"focused_cwd={state.get('focused_cwd')!r}")
|
||||
|
|
@ -159,8 +182,6 @@ def main() -> int:
|
|||
# First cd to test_dir_b so we have a different dir to inherit
|
||||
print(" [test2] cd to test_dir_b, then creating new workspace tab...")
|
||||
_send_cd_and_wait(client, test_dir_b)
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
original_tab = state.get("tab", "")
|
||||
|
||||
tab_result = client.new_tab()
|
||||
if not tab_result:
|
||||
|
|
@ -170,23 +191,14 @@ def main() -> int:
|
|||
return 1
|
||||
check("new tab created", True)
|
||||
|
||||
# New workspace should be a different tab AND inherit test_dir_b
|
||||
# Focus the returned workspace explicitly, then assert it inherited cwd.
|
||||
new_tab = tab_result.strip()
|
||||
client.select_tab(new_tab)
|
||||
time.sleep(4)
|
||||
try:
|
||||
def _new_tab_with_cwd():
|
||||
s = _parse_sidebar_state(client.sidebar_state())
|
||||
tab_id = s.get("tab", "")
|
||||
cwd = s.get("focused_cwd", "")
|
||||
if tab_id != original_tab and cwd == test_dir_b:
|
||||
return s
|
||||
return None
|
||||
|
||||
state = _wait_for(
|
||||
_new_tab_with_cwd, timeout=15.0, interval=0.3,
|
||||
label=f"new tab with focused_cwd={test_dir_b!r}",
|
||||
state = _wait_for_focused_cwd(
|
||||
client, test_dir_b, timeout=15.0, tab=new_tab,
|
||||
)
|
||||
check("test2: focus moved to new tab", state.get("tab") != original_tab,
|
||||
f"original={original_tab!r}, current={state.get('tab')!r}")
|
||||
check("test2: new workspace inherited test_dir_b",
|
||||
state.get("focused_cwd") == test_dir_b,
|
||||
f"focused_cwd={state.get('focused_cwd')!r}")
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit fa452db181f361514087558a29204bda7e38218f
|
||||
Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826
|
||||
|
|
@ -16,6 +16,7 @@ export async function SiteFooter() {
|
|||
links: [
|
||||
{ label: t("blog"), href: "/blog" },
|
||||
{ label: t("community"), href: "/community" },
|
||||
{ label: t("nightly"), href: "/nightly" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
99
web/app/[locale]/nightly/page.tsx
Normal file
99
web/app/[locale]/nightly/page.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { SiteHeader } from "../components/site-header";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "nightly" });
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"underline underline-offset-2 decoration-border hover:decoration-foreground transition-colors";
|
||||
|
||||
export default function NightlyPage() {
|
||||
const t = useTranslations("nightly");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<SiteHeader section={t("title")} />
|
||||
<main className="w-full max-w-2xl mx-auto px-6 py-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src="/logo-nightly.png"
|
||||
alt="cmux NIGHTLY icon"
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("title")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className="text-[15px] text-muted mb-8"
|
||||
style={{ lineHeight: 1.5 }}
|
||||
>
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{/* Download button */}
|
||||
<a
|
||||
href="https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg"
|
||||
className="inline-flex items-center gap-2.5 rounded-full font-medium bg-foreground hover:opacity-85 transition-opacity px-5 py-2.5 text-[15px]"
|
||||
style={{ color: "var(--background)", textDecoration: "none" }}
|
||||
>
|
||||
<svg
|
||||
width={16}
|
||||
height={19}
|
||||
viewBox="0 0 814 1000"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57.8-155.5-127.4c-58.3-81.6-105.6-208.4-105.6-328.6 0-193 125.6-295.5 249.2-295.5 65.7 0 120.5 43.1 161.7 43.1 39.2 0 100.4-45.8 175.1-45.8 28.3 0 130.3 2.6 197.2 99.2zM554.1 159.4c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.9 32.4-57.2 83.6-57.2 135.4 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 137.6-71.2z" />
|
||||
</svg>
|
||||
{t("download")}
|
||||
</a>
|
||||
|
||||
<p
|
||||
className="text-[15px] text-muted mt-8"
|
||||
style={{ lineHeight: 1.5 }}
|
||||
>
|
||||
{t.rich("warning", {
|
||||
githubLink: (chunks) => (
|
||||
<a
|
||||
href="https://github.com/manaflow-ai/cmux/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
discordLink: (chunks) => (
|
||||
<a
|
||||
href="https://discord.gg/xsgFEVrWCZ"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
{ path: "/docs/browser-automation", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
|
||||
{ path: "/community", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
|
||||
{ path: "/wall-of-love", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
|
||||
{ path: "/nightly", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.6 },
|
||||
];
|
||||
|
||||
const entries: MetadataRoute.Sitemap = [];
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "تواصل معنا",
|
||||
"nightly": "إصدار ليلي",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "اللغة"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "أستخدمه منذ أسبوع وهو رائع. علامة تبويب عمودية لكل مهمة قيد التنفيذ. بالداخل، Claude على جانب والمتصفح مع PR والموارد على الجانب الآخر، أتنقل بين المهام وأبقى منظماً. امزج ذلك مع المهارات لجعل Claude يراقب CI بشكل متكرر وما إلى ذلك. أشعر بالتنوير بصراحة",
|
||||
"tonkotsuboy": "انتقلت من Warp إلى Ghostty في بداية السنة، لكن الآن انتقلت إلى cmux. علامات التبويب العمودية مريحة، وأقدر الإشعارات عندما تنتهي مهام Claude Code. هو مبني على Ghostty لذا الأداء السريع ينتقل معه. عرض الفرع والإكمالات التي أعددتها في Ghostty لا تزال تعمل أيضاً."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "أحدث الإصدارات من الفرع الرئيسي",
|
||||
"metaTitle": "cmux NIGHTLY — إصدارات ليلية",
|
||||
"metaDescription": "حمّل cmux NIGHTLY، تطبيق مستقل يُبنى تلقائياً من أحدث commit على main. يعمل بجانب النسخة المستقرة مع تحديثات تلقائية خاصة به.",
|
||||
"description": "يُبنى cmux NIGHTLY تلقائياً من أحدث commit على main. يمتلك معرّف حزمة خاص به، لذا يعمل بجانب النسخة المستقرة دون تعارض. استخدمه لاختبار الميزات الجديدة قبل إصدارها.",
|
||||
"download": "تحميل NIGHTLY لنظام Mac",
|
||||
"warning": "قد تحتوي الإصدارات الليلية على أخطاء أو ميزات غير مكتملة. إذا حدثت مشكلة، أبلغ عنها على <githubLink>GitHub</githubLink> أو في <discordLink>#nightly-bugs على Discord</discordLink> وارجع إلى النسخة المستقرة."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "اللغة"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Jezik"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Koristim ovo sedmicu i fantastično je. Vertikalni tab za svaki zadatak u toku. Unutra, Claude na jednoj strani a preglednik sa PR-ovima i resursima na drugoj, prebacujem se između zadataka i ostajam organizovan. Pomiješajte to sa skillovima da Claude prati CI rekurzivno itd. osjećam se prosvijećenim iskreno",
|
||||
"tonkotsuboy": "Prešao sam sa Warpa na Ghostty početkom godine, ali sad sam prešao na cmux. Vertikalni tabovi su praktični, i cijenim što dobijem notifikaciju kada Claude Code zadaci završe. Baziran je na Ghostty-ju tako da munjevite performanse ostaju. Prikaz grane i completioni koje sam podesio u Ghostty-ju i dalje rade."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Najnovije verzije iz main grane",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly verzije",
|
||||
"metaDescription": "Preuzmite cmux NIGHTLY, zasebnu aplikaciju koja se automatski kompajlira iz posljednjeg commita na main. Radi uporedo sa stabilnom verzijom s vlastitim automatskim ažuriranjima.",
|
||||
"description": "cmux NIGHTLY se automatski kompajlira iz posljednjeg commita na main. Ima vlastiti bundle ID, pa radi uporedo sa stabilnom verzijom bez konflikata. Koristite ga za testiranje novih funkcija prije objavljivanja.",
|
||||
"download": "Preuzmi NIGHTLY za Mac",
|
||||
"warning": "Nightly verzije mogu sadržavati greške ili nepotpune funkcije. Ako nešto ne radi, prijavite to na <githubLink>GitHubu</githubLink> ili u <discordLink>#nightly-bugs na Discordu</discordLink> i prebacite se na stabilnu verziju."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Jezik"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Sprog"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Har brugt det i en uge og det er fantastisk. Vertikal fane for hver igangværende opgave. Indeni, Claude på den ene side og browser med PR og ressourcer på den anden, skift mellem opgaver og hold orden. Bland det med skills så Claude kan overvåge CI rekursivt, osv. føler mig oplyst ærlig talt",
|
||||
"tonkotsuboy": "Jeg skiftede fra Warp til Ghostty i starten af året, men nu er jeg skiftet til cmux. De vertikale faner er praktiske, og jeg sætter pris på at blive notificeret når Claude Code-opgaver er færdige. Det er Ghostty-baseret så den lynhurtige ydeevne følger med. Branch-visning og completions jeg satte op i Ghostty virker stadig."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Seneste builds fra main",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly Builds",
|
||||
"metaDescription": "Download cmux NIGHTLY, en separat app bygget automatisk fra det seneste main-commit. Kører ved siden af den stabile version med egne automatiske opdateringer.",
|
||||
"description": "cmux NIGHTLY bygges automatisk fra det seneste commit på main. Den har sit eget bundle-ID, så den kører ved siden af den stabile version uden konflikter. Brug den til at teste nye funktioner før de udkommer.",
|
||||
"download": "Download NIGHTLY til Mac",
|
||||
"warning": "Nightly builds kan indeholde fejl eller ufærdige funktioner. Hvis noget går galt, rapportér det på <githubLink>GitHub</githubLink> eller i <discordLink>#nightly-bugs på Discord</discordLink> og skift tilbage til den stabile version."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Sprog"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Sprache"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Nutze das seit einer Woche und es ist fantastisch. Ein vertikaler Tab pro WIP-Aufgabe. Darin Claude auf einer Seite und Browser mit PR und Ressourcen auf der anderen. Zwischen Aufgaben wechseln und organisiert bleiben. Dazu Skills, damit Claude CI rekursiv überwacht usw. Fühle mich ehrlich gesagt erleuchtet.",
|
||||
"tonkotsuboy": "Anfang des Jahres bin ich von Warp zu Ghostty gewechselt, aber jetzt bin ich bei cmux. Die vertikalen Tabs sind praktisch, und ich schätze die Benachrichtigungen, wenn Claude-Code-Aufgaben fertig sind. Da es auf Ghostty basiert, bleibt die blitzschnelle Performance erhalten. Branch-Anzeige und Vervollständigungen, die ich in Ghostty eingerichtet hatte, funktionieren auch weiterhin."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Aktuelle Builds vom main-Branch",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly Builds",
|
||||
"metaDescription": "Laden Sie cmux NIGHTLY herunter, eine separate App, die automatisch aus dem neuesten main-Commit erstellt wird. Läuft neben der stabilen Version mit eigenen Auto-Updates.",
|
||||
"description": "cmux NIGHTLY wird automatisch aus dem neuesten Commit auf main erstellt. Es hat eine eigene Bundle-ID und läuft daher ohne Konflikte neben der stabilen Version. Damit können Sie neue Funktionen testen, bevor sie veröffentlicht werden.",
|
||||
"download": "NIGHTLY für Mac herunterladen",
|
||||
"warning": "Nightly Builds können Fehler oder unfertige Funktionen enthalten. Falls Probleme auftreten, melden Sie diese auf <githubLink>GitHub</githubLink> oder in <discordLink>#nightly-bugs auf Discord</discordLink> und wechseln Sie zur stabilen Version."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Sprache"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contact",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Language"
|
||||
},
|
||||
|
|
@ -583,6 +584,15 @@
|
|||
"connorelsea": "Been using this for a week and it's fantastic. Vert tab for each WIP task. Inside, claudes on one side and browser with PR and resources on the other, switch between tasks and stay organized. Mix that with skills to have Claude watch CI recursively, etc. feeling enlightened tbh",
|
||||
"tonkotsuboy": "I switched from Warp to Ghostty at the start of the year, but now I've switched to cmux. The vertical tabs are convenient, and I appreciate getting notified when Claude Code tasks finish. It's Ghostty-based so the blazing fast performance carries over. Branch display and completions I set up in Ghostty still work too."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Bleeding-edge builds from main",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly Builds",
|
||||
"metaDescription": "Download cmux NIGHTLY, a separate app built automatically from the latest main commit. Runs alongside the stable version with its own auto-updates.",
|
||||
"description": "cmux NIGHTLY is built automatically from the latest commit on main. It has its own bundle ID, so it runs alongside the stable version without conflicts. Use it to test new features before they ship.",
|
||||
"download": "Download NIGHTLY for Mac",
|
||||
"warning": "Nightly builds may contain bugs or incomplete features. If something breaks, report it on <githubLink>GitHub</githubLink> or in <discordLink>#nightly-bugs on Discord</discordLink>, and switch back to the stable release."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Language"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contacto",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Idioma"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Lo llevo usando una semana y es fantástico. Una pestaña vertical por cada tarea WIP. Dentro, Claude a un lado y navegador con PR y recursos al otro. Cambiar entre tareas y mantener todo organizado. Combinado con skills para que Claude vigile CI recursivamente, etc. Sinceramente me siento iluminado.",
|
||||
"tonkotsuboy": "A principios de año cambié de Warp a Ghostty, pero ahora me cambié a cmux. Las pestañas verticales son cómodas y agradezco las notificaciones cuando terminan las tareas de Claude Code. Al estar basado en Ghostty, el rendimiento ultrarrápido se mantiene. La visualización de ramas y las completaciones que configuré en Ghostty siguen funcionando."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Compilaciones de última hora desde main",
|
||||
"metaTitle": "cmux NIGHTLY — Compilaciones Nightly",
|
||||
"metaDescription": "Descarga cmux NIGHTLY, una app independiente compilada automáticamente desde el último commit en main. Funciona junto a la versión estable con sus propias actualizaciones automáticas.",
|
||||
"description": "cmux NIGHTLY se compila automáticamente desde el último commit en main. Tiene su propio bundle ID, así que funciona junto a la versión estable sin conflictos. Úsala para probar nuevas funciones antes de su lanzamiento.",
|
||||
"download": "Descargar NIGHTLY para Mac",
|
||||
"warning": "Las compilaciones nightly pueden contener errores o funciones incompletas. Si algo falla, repórtalo en <githubLink>GitHub</githubLink> o en <discordLink>#nightly-bugs en Discord</discordLink> y cambia a la versión estable."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Idioma"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contact",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Langue"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Je l'utilise depuis une semaine et c'est fantastique. Un onglet vertical par tache en cours. A l'interieur, Claude d'un cote et le navigateur avec la PR et les ressources de l'autre. Basculer entre les taches en restant organise. En combinant avec les skills pour que Claude surveille le CI recursivement, etc. Franchement, je me sens eclaire.",
|
||||
"tonkotsuboy": "J'etais passe de Warp a Ghostty en debut d'annee, mais maintenant je suis passe a cmux. Les onglets verticaux sont pratiques, et j'apprecie les notifications quand les taches Claude Code sont terminees. Comme c'est base sur Ghostty, les performances ultra-rapides sont conservees. L'affichage des branches et les completions que j'avais configures dans Ghostty fonctionnent toujours."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Builds de pointe depuis main",
|
||||
"metaTitle": "cmux NIGHTLY — Builds Nightly",
|
||||
"metaDescription": "Téléchargez cmux NIGHTLY, une app séparée compilée automatiquement depuis le dernier commit sur main. Fonctionne à côté de la version stable avec ses propres mises à jour automatiques.",
|
||||
"description": "cmux NIGHTLY est compilé automatiquement depuis le dernier commit sur main. Il possède son propre bundle ID et fonctionne donc à côté de la version stable sans conflit. Utilisez-le pour tester les nouvelles fonctionnalités avant leur sortie.",
|
||||
"download": "Télécharger NIGHTLY pour Mac",
|
||||
"warning": "Les builds nightly peuvent contenir des bugs ou des fonctionnalités incomplètes. En cas de problème, signalez-le sur <githubLink>GitHub</githubLink> ou dans <discordLink>#nightly-bugs sur Discord</discordLink> et revenez à la version stable."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Langue"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contatti",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Lingua"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Lo uso da una settimana ed è fantastico. Un tab verticale per ogni task in corso. Dentro, Claude da un lato e il browser con PR e risorse dall'altro, passo tra i task e resto organizzato. Combinalo con le skill per far monitorare la CI a Claude ricorsivamente, ecc. mi sento illuminato onestamente",
|
||||
"tonkotsuboy": "A inizio anno sono passato da Warp a Ghostty, ma ora sono passato a cmux. I tab verticali sono comodi e apprezzo le notifiche quando i task di Claude Code finiscono. È basato su Ghostty quindi le prestazioni fulminee restano. Anche la visualizzazione del branch e i completamenti che avevo impostato su Ghostty funzionano ancora."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Build di ultima generazione dal branch main",
|
||||
"metaTitle": "cmux NIGHTLY — Build Nightly",
|
||||
"metaDescription": "Scarica cmux NIGHTLY, un'app separata compilata automaticamente dall'ultimo commit su main. Funziona accanto alla versione stabile con aggiornamenti automatici propri.",
|
||||
"description": "cmux NIGHTLY viene compilata automaticamente dall'ultimo commit su main. Ha un proprio bundle ID, quindi funziona accanto alla versione stabile senza conflitti. Usala per testare le nuove funzionalità prima del rilascio.",
|
||||
"download": "Scarica NIGHTLY per Mac",
|
||||
"warning": "Le build nightly possono contenere bug o funzionalità incomplete. In caso di problemi, segnalali su <githubLink>GitHub</githubLink> o in <discordLink>#nightly-bugs su Discord</discordLink> e torna alla versione stabile."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Lingua"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "お問い合わせ",
|
||||
"nightly": "ナイトリー",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "言語"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "1週間使ってるけど最高。WIPタスクごとに縦タブ。中にはClaudeを片側に、PRやリソースのブラウザをもう片側に。タスクを切り替えながら整理できる。スキルでClaudeにCIを再帰的に監視させたり。正直、悟りを開いた気分。",
|
||||
"tonkotsuboy": "年初にWarpからGhosttyに乗り換えたけど、今はcmuxに乗り換えた💻 垂直タブが便利で、Claude Codeのタスクの終了が通知されるのがありがたい。Ghosttyベースだから爆速動作はそのまま。ghosttyでやったブランチ表示や補完もそのまま使える"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "mainブランチからの最新ビルド",
|
||||
"metaTitle": "cmux NIGHTLY — ナイトリービルド",
|
||||
"metaDescription": "cmux NIGHTLYをダウンロード。mainの最新コミットから自動ビルドされる独立アプリ。安定版と並行して動作し、独自の自動アップデート機能付き。",
|
||||
"description": "cmux NIGHTLYはmainの最新コミットから自動ビルドされます。独自のバンドルIDを持つため、安定版と競合せず並行して動作します。新機能をリリース前にテストできます。",
|
||||
"download": "Mac版 NIGHTLYをダウンロード",
|
||||
"warning": "ナイトリービルドにはバグや未完成の機能が含まれる場合があります。問題が発生した場合は<githubLink>GitHub</githubLink>または<discordLink>Discordの#nightly-bugs</discordLink>で報告し、安定版に切り替えてください。"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "言語"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "ទំនាក់ទំនង",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "ភាសា"
|
||||
},
|
||||
|
|
@ -577,6 +578,15 @@
|
|||
"connorelsea": "ប្រើមកមួយសប្តាហ៍ហើយ វាល្អខ្លាំង។ ផ្ទាំងបញ្ឈរសម្រាប់កិច្ចការនីមួយៗ។ ខាងក្នុង Claude នៅម្ខាង កម្មវិធីរុករកជាមួយ PR និងធនធាននៅម្ខាង ប្ដូររវាងកិច្ចការហើយរក្សាការរៀបចំ។ ផ្សំជាមួយ skills ឱ្យ Claude តាមដាន CI ដដែលៗ ។ រឹតតែស្រស់បំព្រង",
|
||||
"tonkotsuboy": "ខ្ញុំប្ដូរពី Warp មក Ghostty ដើមឆ្នាំ ប៉ុន្តែឥឡូវខ្ញុំប្ដូរមក cmux។ ផ្ទាំងបញ្ឈរងាយស្រួល ហើយខ្ញុំពេញចិត្តដែលទទួលបានជូនដំណឹងពេល Claude Code បានបញ្ចប់។ វាផ្អែកលើ Ghostty ដូច្នេះល្បឿនលឿនប្រែកៗនៅតែមាន។ ការបង្ហាញ branch និង completion ដែលខ្ញុំបានកំណត់ក្នុង Ghostty នៅតែដំណើរការដែរ។"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "កំណែចុងក្រោយពីសាខា main",
|
||||
"metaTitle": "cmux NIGHTLY — កំណែ Nightly",
|
||||
"metaDescription": "ទាញយក cmux NIGHTLY កម្មវិធីដាច់ដោយឡែកដែលត្រូវបានបង្កើតដោយស្វ័យប្រវត្តិពី commit ចុងក្រោយនៅលើ main។ ដំណើរការស្របជាមួយកំណែស្ថិរភាពជាមួយការអាប់ដេតស្វ័យប្រវត្តិផ្ទាល់ខ្លួន។",
|
||||
"description": "cmux NIGHTLY ត្រូវបានបង្កើតដោយស្វ័យប្រវត្តិពី commit ចុងក្រោយនៅលើ main។ វាមាន bundle ID ផ្ទាល់ខ្លួន ដូច្នេះវាដំណើរការស្របជាមួយកំណែស្ថិរភាពដោយគ្មានជម្លោះ។ ប្រើវាដើម្បីសាកល្បងមុខងារថ្មីមុនពេលចេញផ្សាយ។",
|
||||
"download": "ទាញយក NIGHTLY សម្រាប់ Mac",
|
||||
"warning": "កំណែ nightly អាចមានកំហុស ឬមុខងារមិនទាន់ពេញលេញ។ ប្រសិនបើមានបញ្ហា សូមរាយការណ៍នៅលើ <githubLink>GitHub</githubLink> ឬក្នុង <discordLink>#nightly-bugs នៅលើ Discord</discordLink> ហើយប្តូរទៅកំណែស្ថិរភាពវិញ។"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "ភាសា"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "문의",
|
||||
"nightly": "나이틀리",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "언어"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "일주일째 쓰고 있는데 환상적이에요. WIP 작업마다 세로 탭 하나씩. 안에는 한쪽에 Claude, 다른 쪽에 PR과 리소스 브라우저. 작업 전환하면서 정리가 돼요. 스킬로 Claude에게 CI를 재귀적으로 감시시키는 것도 가능. 솔직히 깨달음을 얻은 기분.",
|
||||
"tonkotsuboy": "연초에 Warp에서 Ghostty로 갈아탔는데, 이제는 cmux로 갈아탔어요. 세로 탭이 편하고, Claude Code 작업이 끝나면 알림이 와서 좋아요. Ghostty 기반이라 빠른 성능은 그대로. Ghostty에서 설정한 브랜치 표시랑 자동완성도 그대로 쓸 수 있어요."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "main 브랜치의 최신 빌드",
|
||||
"metaTitle": "cmux NIGHTLY — 나이틀리 빌드",
|
||||
"metaDescription": "cmux NIGHTLY를 다운로드하세요. main의 최신 커밋에서 자동으로 빌드되는 독립 앱입니다. 안정 버전과 나란히 실행되며 독자적인 자동 업데이트를 제공합니다.",
|
||||
"description": "cmux NIGHTLY는 main의 최신 커밋에서 자동으로 빌드됩니다. 자체 번들 ID를 가지고 있어 안정 버전과 충돌 없이 나란히 실행됩니다. 출시 전에 새로운 기능을 테스트할 수 있습니다.",
|
||||
"download": "Mac용 NIGHTLY 다운로드",
|
||||
"warning": "나이틀리 빌드에는 버그나 미완성 기능이 포함될 수 있습니다. 문제가 발생하면 <githubLink>GitHub</githubLink> 또는 <discordLink>Discord의 #nightly-bugs</discordLink>에서 보고하고 안정 버전으로 전환하세요."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "언어"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Språk"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Har brukt dette i en uke og det er fantastisk. Vertikal fane for hver pågående oppgave. Inni har jeg Claude på den ene siden og nettleser med PR og ressurser på den andre, bytter mellom oppgaver og holder orden. Bland det med skills for å la Claude overvåke CI rekursivt, osv. Føler meg opplyst tbh",
|
||||
"tonkotsuboy": "Jeg byttet fra Warp til Ghostty i begynnelsen av året, men nå har jeg byttet til cmux. De vertikale fanene er praktiske, og jeg setter pris på å bli varslet når Claude Code-oppgaver er ferdige. Det er Ghostty-basert, så den lynraske ytelsen følger med. Grenvisning og autofullføringer jeg satte opp i Ghostty fungerer fortsatt også."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Nyeste bygg fra main",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly-bygg",
|
||||
"metaDescription": "Last ned cmux NIGHTLY, en separat app som bygges automatisk fra siste main-commit. Kjører ved siden av den stabile versjonen med egne automatiske oppdateringer.",
|
||||
"description": "cmux NIGHTLY bygges automatisk fra siste commit på main. Den har sin egen bundle-ID, så den kjører ved siden av den stabile versjonen uten konflikter. Bruk den til å teste nye funksjoner før de lanseres.",
|
||||
"download": "Last ned NIGHTLY for Mac",
|
||||
"warning": "Nightly-bygg kan inneholde feil eller uferdige funksjoner. Hvis noe går galt, rapporter det på <githubLink>GitHub</githubLink> eller i <discordLink>#nightly-bugs på Discord</discordLink> og bytt tilbake til den stabile versjonen."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Språk"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Język"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Używam tego od tygodnia i jest fantastyczne. Pionowa karta dla każdego zadania w toku. Wewnątrz, Claude po jednej stronie a przeglądarka z PR i zasobami po drugiej, przełączam się między zadaniami i utrzymuję porządek. Połącz to ze skillami żeby Claude monitorował CI rekursywnie itp. czuję się oświecony szczerze mówiąc",
|
||||
"tonkotsuboy": "Na początku roku przeszedłem z Warpa na Ghostty, ale teraz przeszedłem na cmux. Pionowe karty są wygodne i doceniam powiadomienia gdy zadania Claude Code się kończą. Jest oparty na Ghostty więc błyskawiczna wydajność zostaje. Wyświetlanie brancha i uzupełniania które skonfigurowałem w Ghostty nadal działają."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Najnowsze buildy z gałęzi main",
|
||||
"metaTitle": "cmux NIGHTLY — Buildy Nightly",
|
||||
"metaDescription": "Pobierz cmux NIGHTLY, osobną aplikację budowaną automatycznie z najnowszego commita na main. Działa obok wersji stabilnej z własnymi aktualizacjami automatycznymi.",
|
||||
"description": "cmux NIGHTLY jest budowany automatycznie z najnowszego commita na main. Ma własne bundle ID, więc działa obok wersji stabilnej bez konfliktów. Używaj go, aby testować nowe funkcje przed ich wydaniem.",
|
||||
"download": "Pobierz NIGHTLY na Maca",
|
||||
"warning": "Buildy nightly mogą zawierać błędy lub niekompletne funkcje. W razie problemów zgłoś je na <githubLink>GitHubie</githubLink> lub w <discordLink>#nightly-bugs na Discordzie</discordLink> i przełącz się na wersję stabilną."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Język"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contato",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Idioma"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Usando há uma semana e é fantástico. Aba vertical para cada tarefa em andamento. Dentro, claudes de um lado e navegador com PR e recursos do outro, alterno entre tarefas e mantenho tudo organizado. Misture com skills para o Claude monitorar CI recursivamente, etc. me sinto iluminado pra ser honesto",
|
||||
"tonkotsuboy": "Mudei do Warp para o Ghostty no início do ano, mas agora migrei para o cmux. As abas verticais são práticas e gosto de ser notificado quando tarefas do Claude Code terminam. É baseado no Ghostty, então a performance ultrarrápida se mantém. A exibição de branches e completions que configurei no Ghostty continuam funcionando também."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Builds de ponta do branch main",
|
||||
"metaTitle": "cmux NIGHTLY — Builds Nightly",
|
||||
"metaDescription": "Baixe o cmux NIGHTLY, um app separado compilado automaticamente do commit mais recente no main. Funciona ao lado da versão estável com suas próprias atualizações automáticas.",
|
||||
"description": "O cmux NIGHTLY é compilado automaticamente do commit mais recente no main. Ele tem seu próprio bundle ID, então funciona ao lado da versão estável sem conflitos. Use-o para testar novos recursos antes do lançamento.",
|
||||
"download": "Baixar NIGHTLY para Mac",
|
||||
"warning": "Builds nightly podem conter bugs ou recursos incompletos. Se algo quebrar, reporte no <githubLink>GitHub</githubLink> ou em <discordLink>#nightly-bugs no Discord</discordLink> e volte para a versão estável."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Idioma"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Контакты",
|
||||
"nightly": "Ночные сборки",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Язык"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Использую неделю и это фантастика. Вертикальная вкладка для каждой текущей задачи. Внутри Claude с одной стороны и браузер с PR и ресурсами с другой, переключаюсь между задачами и остаюсь организованным. Сочетай это со скиллами чтобы Claude рекурсивно следил за CI и т.д. чувствую себя просветлённым честно говоря",
|
||||
"tonkotsuboy": "В начале года перешёл с Warp на Ghostty, а теперь перешёл на cmux. Вертикальные вкладки удобны, и ценю уведомления когда задачи Claude Code завершаются. Он на базе Ghostty, так что молниеносная скорость сохраняется. Отображение веток и автодополнения, которые я настроил в Ghostty, тоже работают."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Актуальные сборки из ветки main",
|
||||
"metaTitle": "cmux NIGHTLY — Ночные сборки",
|
||||
"metaDescription": "Скачайте cmux NIGHTLY — отдельное приложение, автоматически собираемое из последнего коммита в main. Работает параллельно со стабильной версией с собственными автообновлениями.",
|
||||
"description": "cmux NIGHTLY автоматически собирается из последнего коммита в main. У него собственный bundle ID, поэтому он работает параллельно со стабильной версией без конфликтов. Используйте его для тестирования новых функций до релиза.",
|
||||
"download": "Скачать NIGHTLY для Mac",
|
||||
"warning": "Ночные сборки могут содержать ошибки или незавершённые функции. Если что-то сломалось, сообщите на <githubLink>GitHub</githubLink> или в <discordLink>#nightly-bugs в Discord</discordLink> и переключитесь на стабильную версию."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Язык"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "ติดต่อ",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "ภาษา"
|
||||
},
|
||||
|
|
@ -577,6 +578,15 @@
|
|||
"connorelsea": "ใช้มาสัปดาห์นึงแล้ว เยี่ยมมาก แท็บแนวตั้งสำหรับแต่ละงานที่ทำอยู่ ข้างในมี Claude อยู่ด้านนึงและเบราว์เซอร์กับ PR และทรัพยากรอยู่อีกด้าน สลับไปมาระหว่างงานได้อย่างเป็นระเบียบ ผสมกับ skills ให้ Claude คอยดู CI แบบ recursive ฯลฯ รู้สึกตาสว่างเลย",
|
||||
"tonkotsuboy": "ผมเปลี่ยนจาก Warp มา Ghostty ตอนต้นปี แต่ตอนนี้เปลี่ยนมา cmux แล้ว แท็บแนวตั้งสะดวกดี และชอบที่แจ้งเตือนเมื่องาน Claude Code เสร็จ มันใช้ Ghostty เป็นฐานก็เลยเร็วเหมือนเดิม การแสดง branch และ completion ที่ตั้งไว้ใน Ghostty ก็ยังใช้ได้อยู่"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "บิลด์ล่าสุดจาก main",
|
||||
"metaTitle": "cmux NIGHTLY — บิลด์ Nightly",
|
||||
"metaDescription": "ดาวน์โหลด cmux NIGHTLY แอปแยกที่สร้างอัตโนมัติจาก commit ล่าสุดบน main ทำงานควบคู่กับเวอร์ชันเสถียรพร้อมอัปเดตอัตโนมัติของตัวเอง",
|
||||
"description": "cmux NIGHTLY สร้างอัตโนมัติจาก commit ล่าสุดบน main มี bundle ID เป็นของตัวเอง จึงทำงานควบคู่กับเวอร์ชันเสถียรได้โดยไม่ขัดแย้ง ใช้เพื่อทดสอบฟีเจอร์ใหม่ก่อนเปิดตัว",
|
||||
"download": "ดาวน์โหลด NIGHTLY สำหรับ Mac",
|
||||
"warning": "บิลด์ nightly อาจมีบั๊กหรือฟีเจอร์ที่ยังไม่สมบูรณ์ หากพบปัญหา รายงานบน <githubLink>GitHub</githubLink> หรือใน <discordLink>#nightly-bugs บน Discord</discordLink> แล้วสลับกลับไปใช้เวอร์ชันเสถียร"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "ภาษา"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "İletişim",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Dil"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Bir haftadır kullanıyorum ve harika. Her devam eden görev için dikey sekme. İçinde bir tarafta Claude'lar, diğer tarafta PR ve kaynaklarla tarayıcı, görevler arasında geçiş yapıp düzenli kalıyorum. Bunu Claude'un CI'ı özyinelemeli izlemesi için skill'lerle birleştirin, vs. aydınlanmış hissediyorum açıkçası",
|
||||
"tonkotsuboy": "Yılın başında Warp'tan Ghostty'ye geçtim ama şimdi cmux'a geçtim. Dikey sekmeler kullanışlı ve Claude Code görevleri bittiğinde bildirim almayı takdir ediyorum. Ghostty tabanlı olduğu için çok hızlı performans aynen devam ediyor. Ghostty'de ayarladığım dal gösterimi ve tamamlamalar da hâlâ çalışıyor."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "main dalından güncel derlemeler",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly Derlemeler",
|
||||
"metaDescription": "cmux NIGHTLY indirin. main deki en son commit ten otomatik olarak derlenen bağımsız bir uygulama. Kararlı sürümle yan yana çalışır ve kendi otomatik güncellemelerine sahiptir.",
|
||||
"description": "cmux NIGHTLY, main deki en son commit ten otomatik olarak derlenir. Kendi bundle ID sine sahip olduğundan kararlı sürümle çakışmadan yan yana çalışır. Yeni özellikleri yayınlanmadan önce test etmek için kullanın.",
|
||||
"download": "Mac için NIGHTLY indir",
|
||||
"warning": "Nightly derlemeler hatalar veya tamamlanmamış özellikler içerebilir. Bir sorun oluşursa <githubLink>GitHub</githubLink> veya <discordLink>Discord daki #nightly-bugs</discordLink> kanalında bildirin ve kararlı sürüme geçin."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Dil"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "联系我们",
|
||||
"nightly": "每夜构建",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "语言"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "用了一周,非常棒。每个进行中的任务一个垂直标签页。里面一边是 Claude,另一边是浏览器看 PR 和资料,在任务之间切换保持有序。配合 skill 让 Claude 递归监控 CI 等等。感觉开悟了。",
|
||||
"tonkotsuboy": "年初从 Warp 换到 Ghostty,现在又换到了 cmux。垂直标签页很方便,Claude Code 任务完成时收到通知很实用。基于 Ghostty 所以依然飞快。之前在 Ghostty 里设置的分支显示和补全也都能用。"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "来自 main 分支的最新构建",
|
||||
"metaTitle": "cmux NIGHTLY — 每夜构建",
|
||||
"metaDescription": "下载 cmux NIGHTLY,从最新 main 提交自动构建的独立应用。与稳定版并行运行,拥有独立的自动更新。",
|
||||
"description": "cmux NIGHTLY 从 main 的最新提交自动构建。它拥有独立的 Bundle ID,因此可以与稳定版并行运行,互不冲突。用它来测试尚未发布的新功能。",
|
||||
"download": "下载 Mac 版 NIGHTLY",
|
||||
"warning": "每夜构建可能包含错误或不完整的功能。如果遇到问题,请在 <githubLink>GitHub</githubLink> 或 <discordLink>Discord 的 #nightly-bugs</discordLink> 上报告,并切换回稳定版。"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "语言"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "聯絡我們",
|
||||
"nightly": "每夜建置",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "語言"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "用了一週,非常棒。每個進行中的任務一個垂直分頁。裡面一邊是 Claude,另一邊是瀏覽器看 PR 和資料,在任務之間切換保持有序。搭配 skill 讓 Claude 遞迴監控 CI 等等。感覺開悟了。",
|
||||
"tonkotsuboy": "年初從 Warp 換到 Ghostty,現在又換到了 cmux。垂直分頁很方便,Claude Code 任務完成時收到通知很實用。基於 Ghostty 所以依然飛快。之前在 Ghostty 裡設定的分支顯示和補全也都能用。"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "來自 main 分支的最新建置",
|
||||
"metaTitle": "cmux NIGHTLY — 每夜建置",
|
||||
"metaDescription": "下載 cmux NIGHTLY,從最新 main 提交自動建置的獨立應用。與穩定版並行運行,擁有獨立的自動更新。",
|
||||
"description": "cmux NIGHTLY 從 main 的最新提交自動建置。它擁有獨立的 Bundle ID,因此可以與穩定版並行運行,互不衝突。用它來測試尚未發佈的新功能。",
|
||||
"download": "下載 Mac 版 NIGHTLY",
|
||||
"warning": "每夜建置可能包含錯誤或不完整的功能。如果遇到問題,請在 <githubLink>GitHub</githubLink> 或 <discordLink>Discord 的 #nightly-bugs</discordLink> 上回報,並切換回穩定版。"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "語言"
|
||||
}
|
||||
|
|
|
|||
BIN
web/public/logo-nightly.png
Normal file
BIN
web/public/logo-nightly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Loading…
Add table
Add a link
Reference in a new issue