diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5c46f0a3..adcadcad 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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 diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index a37d6096..86445cf5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -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 = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; + FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -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 */, ); diff --git a/README.md b/README.md index 599277e5..d8ddbbdf 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/Resources/Info.plist b/Resources/Info.plist index 48d4f800..f1beb4f9 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -93,15 +93,27 @@ - UTImportedTypeDeclarations + UTExportedTypeDeclarations UTTypeIdentifier com.splittabbar.tabtransfer + UTTypeDescription + Bonsplit Tab Transfer + UTTypeConformsTo + + public.data + UTTypeIdentifier com.cmux.sidebar-tab-reorder + UTTypeDescription + cmux Sidebar Tab Reorder + UTTypeConformsTo + + public.data + NSAppTransportSecurity diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 54aa5c85..ff9de5ea 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": { diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index ab4b6e2c..4a22e3a1 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -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 diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 821f3d19..e9bbf235 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -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 diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 133f44b7..a0a4d27f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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 diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d72a1e5d..8ba9ab34 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1330,6 +1330,8 @@ struct ContentView: View { @State private var retiringWorkspaceId: UUID? @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task? + @State private var didApplyUITestSidebarSelection = false + @State private var workspaceHandoffReadyCheckTask: Task? @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() } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 95445b27..85d68a89 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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: "", + 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? +#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() { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b9f1ca1b..b38a7f5d 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2132,6 +2132,7 @@ final class BrowserPanel: Panel, ObservableObject { } func triggerFlash() { + guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken &+= 1 } diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 74e48b89..2e74944d 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -76,6 +76,7 @@ final class MarkdownPanel: Panel, ObservableObject { } func triggerFlash() { + guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken += 1 } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 3bf394fe..7a8acf00 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -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) } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 200104df..90538955 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -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, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ea1cda35..311166e9 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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? private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)? + private var sidebarSelectedWorkspaceIds: Set = [] + 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) { + 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) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9a430bc0..2ac932ce 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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 [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty [--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 [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command report_pwd [--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 [--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 [--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" diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index fb1f0b90..bd622967 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -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 diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 49167da8..02335c6b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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 = [] + /// 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 = [] + /// 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 } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 96703df9..0df1321e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index d084e71d..26c25bf2 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -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 { + 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) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 4fbe260e..21f7a4db 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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("

Hello world

", 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("", 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("

Hello

", 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 { + 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 diff --git a/cmuxTests/WorkspaceStressProfileTests.swift b/cmuxTests/WorkspaceStressProfileTests.swift new file mode 100644 index 00000000..bebb48d9 --- /dev/null +++ b/cmuxTests/WorkspaceStressProfileTests.swift @@ -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.. 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( + _ 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) + } +} diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index 54c35d19..b9061916 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -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 { diff --git a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift new file mode 100644 index 00000000..c6604cb5 --- /dev/null +++ b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift @@ -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") ?? "")" + ) + 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") ?? "")" + ) + } + + 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") ?? "")" + ) + + 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") ?? "")" + ) + } + + 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 + } +} diff --git a/docs/assets/main-first-image.png b/docs/assets/main-first-image.png index 61a9102e..3b03077b 100644 Binary files a/docs/assets/main-first-image.png and b/docs/assets/main-first-image.png differ diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 0797f4be..49de9988 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -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 diff --git a/ghostty b/ghostty index 80cca8a1..bc9be90a 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 80cca8a12ebd554953fc6b35235135a3e61fe20c +Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index c7c101ac..582f8999 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -5,3 +5,4 @@ a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d 0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de 312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30 +404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd diff --git a/tests/test_split_cwd_inheritance.py b/tests/test_split_cwd_inheritance.py index 6677ee8e..80a5733b 100644 --- a/tests/test_split_cwd_inheritance.py +++ b/tests/test_split_cwd_inheritance.py @@ -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}") diff --git a/vendor/bonsplit b/vendor/bonsplit index fa452db1..73c1ef2d 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit fa452db181f361514087558a29204bda7e38218f +Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826 diff --git a/web/app/[locale]/components/site-footer.tsx b/web/app/[locale]/components/site-footer.tsx index 59f8a2e2..29dd728d 100644 --- a/web/app/[locale]/components/site-footer.tsx +++ b/web/app/[locale]/components/site-footer.tsx @@ -16,6 +16,7 @@ export async function SiteFooter() { links: [ { label: t("blog"), href: "/blog" }, { label: t("community"), href: "/community" }, + { label: t("nightly"), href: "/nightly" }, ], }, { diff --git a/web/app/[locale]/nightly/page.tsx b/web/app/[locale]/nightly/page.tsx new file mode 100644 index 00000000..35af11df --- /dev/null +++ b/web/app/[locale]/nightly/page.tsx @@ -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 ( +
+ +
+ {/* Header */} +
+ cmux NIGHTLY icon +
+

+ {t("title")} +

+
+
+ + {/* Description */} +

+ {t("description")} +

+ + {/* Download button */} + + + + + {t("download")} + + +

+ {t.rich("warning", { + githubLink: (chunks) => ( + + {chunks} + + ), + discordLink: (chunks) => ( + + {chunks} + + ), + })} +

+
+
+ ); +} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index a33ec52d..dfac9bb5 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -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 = []; diff --git a/web/messages/ar.json b/web/messages/ar.json index 7bb57cf4..099b3578 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -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": "قد تحتوي الإصدارات الليلية على أخطاء أو ميزات غير مكتملة. إذا حدثت مشكلة، أبلغ عنها على GitHub أو في #nightly-bugs على Discord وارجع إلى النسخة المستقرة." + }, "languageSwitcher": { "label": "اللغة" } diff --git a/web/messages/bs.json b/web/messages/bs.json index 3be8749d..6210c4b9 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -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 GitHubu ili u #nightly-bugs na Discordu i prebacite se na stabilnu verziju." + }, "languageSwitcher": { "label": "Jezik" } diff --git a/web/messages/da.json b/web/messages/da.json index 0d6e9a93..eeaf4a93 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -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å GitHub eller i #nightly-bugs på Discord og skift tilbage til den stabile version." + }, "languageSwitcher": { "label": "Sprog" } diff --git a/web/messages/de.json b/web/messages/de.json index 76bd5872..df68123f 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -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 GitHub oder in #nightly-bugs auf Discord und wechseln Sie zur stabilen Version." + }, "languageSwitcher": { "label": "Sprache" } diff --git a/web/messages/en.json b/web/messages/en.json index aca6831f..44437938 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -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 GitHub or in #nightly-bugs on Discord, and switch back to the stable release." + }, "languageSwitcher": { "label": "Language" } diff --git a/web/messages/es.json b/web/messages/es.json index 54276f8e..6913eaaa 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -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 GitHub o en #nightly-bugs en Discord y cambia a la versión estable." + }, "languageSwitcher": { "label": "Idioma" } diff --git a/web/messages/fr.json b/web/messages/fr.json index d80c7a36..61426dd9 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -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 GitHub ou dans #nightly-bugs sur Discord et revenez à la version stable." + }, "languageSwitcher": { "label": "Langue" } diff --git a/web/messages/it.json b/web/messages/it.json index 32a2e16b..b002fd81 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -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 GitHub o in #nightly-bugs su Discord e torna alla versione stabile." + }, "languageSwitcher": { "label": "Lingua" } diff --git a/web/messages/ja.json b/web/messages/ja.json index e7ee372d..ea5d4351 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -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": "ナイトリービルドにはバグや未完成の機能が含まれる場合があります。問題が発生した場合はGitHubまたはDiscordの#nightly-bugsで報告し、安定版に切り替えてください。" + }, "languageSwitcher": { "label": "言語" } diff --git a/web/messages/km.json b/web/messages/km.json index c0382be9..128e2142 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -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 អាចមានកំហុស ឬមុខងារមិនទាន់ពេញលេញ។ ប្រសិនបើមានបញ្ហា សូមរាយការណ៍នៅលើ GitHub ឬក្នុង #nightly-bugs នៅលើ Discord ហើយប្តូរទៅកំណែស្ថិរភាពវិញ។" + }, "languageSwitcher": { "label": "ភាសា" }, diff --git a/web/messages/ko.json b/web/messages/ko.json index 38d724a3..156534fd 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -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": "나이틀리 빌드에는 버그나 미완성 기능이 포함될 수 있습니다. 문제가 발생하면 GitHub 또는 Discord의 #nightly-bugs에서 보고하고 안정 버전으로 전환하세요." + }, "languageSwitcher": { "label": "언어" } diff --git a/web/messages/no.json b/web/messages/no.json index cb32966f..b289095e 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -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å GitHub eller i #nightly-bugs på Discord og bytt tilbake til den stabile versjonen." + }, "languageSwitcher": { "label": "Språk" } diff --git a/web/messages/pl.json b/web/messages/pl.json index 87f7a70e..bb52ccbf 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -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 GitHubie lub w #nightly-bugs na Discordzie i przełącz się na wersję stabilną." + }, "languageSwitcher": { "label": "Język" } diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index d7bd0937..8b2b65f3 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -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 GitHub ou em #nightly-bugs no Discord e volte para a versão estável." + }, "languageSwitcher": { "label": "Idioma" } diff --git a/web/messages/ru.json b/web/messages/ru.json index f70ce966..ed2c38c2 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -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": "Ночные сборки могут содержать ошибки или незавершённые функции. Если что-то сломалось, сообщите на GitHub или в #nightly-bugs в Discord и переключитесь на стабильную версию." + }, "languageSwitcher": { "label": "Язык" } diff --git a/web/messages/th.json b/web/messages/th.json index 5697b200..7627ba68 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -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 อาจมีบั๊กหรือฟีเจอร์ที่ยังไม่สมบูรณ์ หากพบปัญหา รายงานบน GitHub หรือใน #nightly-bugs บน Discord แล้วสลับกลับไปใช้เวอร์ชันเสถียร" + }, "languageSwitcher": { "label": "ภาษา" }, diff --git a/web/messages/tr.json b/web/messages/tr.json index d02d01ff..cd6c4da0 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -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 GitHub veya Discord daki #nightly-bugs kanalında bildirin ve kararlı sürüme geçin." + }, "languageSwitcher": { "label": "Dil" } diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 394b890c..e970d95d 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -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": "每夜构建可能包含错误或不完整的功能。如果遇到问题,请在 GitHubDiscord 的 #nightly-bugs 上报告,并切换回稳定版。" + }, "languageSwitcher": { "label": "语言" } diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index e4792805..c6136785 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -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": "每夜建置可能包含錯誤或不完整的功能。如果遇到問題,請在 GitHubDiscord 的 #nightly-bugs 上回報,並切換回穩定版。" + }, "languageSwitcher": { "label": "語言" } diff --git a/web/public/logo-nightly.png b/web/public/logo-nightly.png new file mode 100644 index 00000000..3b9d89a5 Binary files /dev/null and b/web/public/logo-nightly.png differ