diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 39670f97..a63e74e0 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 @@ -102,7 +101,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 @@ -110,7 +109,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 @@ -130,14 +151,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 @@ -150,6 +174,7 @@ jobs: go-version-file: daemon/remote/go.mod - 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: | @@ -162,6 +187,7 @@ jobs: echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" - 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' \ @@ -171,6 +197,7 @@ 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 APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux" @@ -183,15 +210,16 @@ jobs: [[ "$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}')" @@ -203,7 +231,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\`" @@ -211,7 +239,7 @@ 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 }}" @@ -285,7 +313,7 @@ jobs: done - 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 +337,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: | @@ -330,7 +358,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 }} @@ -395,7 +423,7 @@ jobs: "$NIGHTLY_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 @@ -410,7 +438,7 @@ jobs: 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: | @@ -419,6 +447,9 @@ jobs: exit 1 fi ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.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: Attest remote daemon nightly assets if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' @@ -440,16 +471,20 @@ jobs: path: | cmux-nightly-macos*.dmg appcast.xml +<<<<<<< HEAD remote-daemon-assets/cmuxd-remote-darwin-arm64 remote-daemon-assets/cmuxd-remote-darwin-amd64 remote-daemon-assets/cmuxd-remote-linux-arm64 remote-daemon-assets/cmuxd-remote-linux-amd64 remote-daemon-assets/cmuxd-remote-checksums.txt remote-daemon-assets/cmuxd-remote-manifest.json +======= + appcast-universal.xml +>>>>>>> origin/main 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]" @@ -458,7 +493,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 @@ -471,18 +506,23 @@ jobs: **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) files: | cmux-nightly-macos-${{ github.run_id }}*.dmg cmux-nightly-macos.dmg appcast.xml +<<<<<<< HEAD remote-daemon-assets/cmuxd-remote-darwin-arm64 remote-daemon-assets/cmuxd-remote-darwin-amd64 remote-daemon-assets/cmuxd-remote-linux-arm64 remote-daemon-assets/cmuxd-remote-linux-amd64 remote-daemon-assets/cmuxd-remote-checksums.txt remote-daemon-assets/cmuxd-remote-manifest.json +======= + appcast-universal.xml +>>>>>>> origin/main overwrite_files: true - name: Cleanup keychain 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/Sources/ContentView.swift b/Sources/ContentView.swift index bce7a8d5..76d36296 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1368,6 +1368,7 @@ struct ContentView: View { @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) @@ -2919,6 +2920,8 @@ struct ContentView: View { retiringWorkspaceId = nil workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil + workspaceHandoffReadyCheckTask?.cancel() + workspaceHandoffReadyCheckTask = nil return } @@ -2926,6 +2929,7 @@ struct ContentView: View { let generation = workspaceHandoffGeneration retiringWorkspaceId = oldSelectedId workspaceHandoffFallbackTask?.cancel() + workspaceHandoffReadyCheckTask?.cancel() #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { @@ -2941,6 +2945,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) @@ -2960,9 +2994,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 @@ -7259,6 +7304,9 @@ struct VerticalTabsSidebar: View { } var body: some View { + let workspaceCount = tabManager.tabs.count + let canCloseWorkspace = workspaceCount > 1 + VStack(spacing: 0) { GeometryReader { proxy in ScrollView { @@ -7282,7 +7330,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, @@ -9555,7 +9608,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 && @@ -9574,7 +9629,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 @@ -9681,12 +9738,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? { @@ -10444,7 +10497,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) { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6c75cb51..f1b29c58 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 } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index b3abe290..8721e52d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -664,6 +664,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: [ @@ -740,6 +767,9 @@ class TabManager: ObservableObject { 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 @@ -916,10 +946,22 @@ class TabManager: ObservableObject { } else { tabs.append(newWorkspace) } + if overrideWorkingDirectory != nil, + let workingDirectory, + let panelId = newWorkspace.focusedTerminalPanel?.id { + scheduleInitialWorkspaceGitMetadataRefresh( + workspaceId: newWorkspace.id, + panelId: panelId, + directory: workingDirectory + ) + } if eagerLoadTerminal { newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() } if select { +#if DEBUG + debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id) +#endif selectedTabId = newWorkspace.id NotificationCenter.default.post( name: .ghosttyDidFocusTab, @@ -1495,6 +1537,9 @@ class TabManager: ObservableObject { } func selectWorkspace(_ workspace: Workspace) { +#if DEBUG + debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id) +#endif selectedTabId = workspace.id } @@ -2077,6 +2122,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, @@ -2144,13 +2192,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 @@ -2162,13 +2204,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 @@ -2241,6 +2277,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)) @@ -2253,6 +2323,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 } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index a152d048..d6a25164 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3740,7 +3740,7 @@ class TerminalController { "close_left", "close_right", "close_others", "new_terminal_right", "new_browser_right", "reload", "duplicate", - "pin", "unpin", "mark_unread" + "pin", "unpin", "mark_read", "mark_unread" ] var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [ @@ -3854,6 +3854,10 @@ class TerminalController { workspace.setPanelPinned(panelId: surfaceId, pinned: false) finish(["pinned": false]) + case "mark_read": + workspace.markPanelRead(surfaceId) + finish() + case "mark_unread", "mark_as_unread": workspace.markPanelUnread(surfaceId) finish() @@ -4037,7 +4041,7 @@ class TerminalController { "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, "type": panel.panelType.rawValue, - "title": panel.displayTitle, + "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, "focused": panel.id == focusedSurfaceId, "pane_id": v2OrNull(paneUUID?.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), @@ -5325,7 +5329,7 @@ class TerminalController { if sourcePaneUUID == targetPaneUUID { return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) v2MainSync { @@ -5408,7 +5412,7 @@ class TerminalController { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) v2MainSync { @@ -5449,7 +5453,7 @@ class TerminalController { return } - let destinationWorkspace = tabManager.addWorkspace() + let destinationWorkspace = tabManager.addWorkspace(select: focus) guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first else { if let sourcePaneForRollback { @@ -5476,10 +5480,6 @@ class TerminalController { result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) return } - - if !focus { - tabManager.selectWorkspace(sourceWorkspace) - } let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index cc4bf895..65c6e07b 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 @@ -872,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 { 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/docs/ghostty-fork.md b/docs/ghostty-fork.md index d85ca46b..c57c12e6 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. ## Upstreamed fork changes diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 47fb3d9d..b6cd6653 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -6,3 +6,4 @@ a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933 c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df 0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de 312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30 +404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd diff --git a/tests_v2/test_pane_break_swap_preserve_focus.py b/tests_v2/test_pane_break_swap_preserve_focus.py new file mode 100644 index 00000000..c9cfe722 --- /dev/null +++ b/tests_v2/test_pane_break_swap_preserve_focus.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Regression: pane.swap and pane.break should not steal visible focus.""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _focused_pane_id(client: cmux, workspace_id: str) -> str: + payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + for row in payload.get("panes") or []: + if bool(row.get("focused")): + return str(row.get("id") or "") + return "" + + +def main() -> int: + created_workspaces: list[str] = [] + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = client.new_workspace() + created_workspaces.append(workspace_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + _ = client.new_split("right") + time.sleep(0.2) + + panes_payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + panes = panes_payload.get("panes") or [] + _must(len(panes) == 2, f"expected two panes after split: {panes_payload}") + + focused_row = next((row for row in panes if bool(row.get("focused"))), None) + _must(focused_row is not None, f"expected focused pane after split: {panes_payload}") + focused_pane_id = str(focused_row.get("id") or "") + other_row = next((row for row in panes if str(row.get("id") or "") != focused_pane_id), None) + _must(other_row is not None, f"expected non-focused pane after split: {panes_payload}") + other_pane_id = str(other_row.get("id") or "") + + client.focus_pane(other_pane_id) + time.sleep(0.2) + _must( + _focused_pane_id(client, workspace_id) == other_pane_id, + "expected explicit pane focus before pane.swap regression check", + ) + + client._call("pane.swap", {"pane_id": other_pane_id, "target_pane_id": focused_pane_id}) + time.sleep(0.2) + _must( + _focused_pane_id(client, workspace_id) == other_pane_id, + "pane.swap should preserve the currently focused pane when invoked over the socket", + ) + _must( + client.current_workspace() == workspace_id, + "pane.swap should not change the selected workspace", + ) + + broken_payload = client._call("pane.break", {"pane_id": other_pane_id}) or {} + broken_workspace_id = str(broken_payload.get("workspace_id") or "") + _must(bool(broken_workspace_id), f"pane.break returned no workspace_id: {broken_payload}") + created_workspaces.append(broken_workspace_id) + time.sleep(0.2) + + _must( + client.current_workspace() == workspace_id, + "pane.break should preserve the selected workspace when invoked over the socket", + ) + finally: + with cmux(SOCKET_PATH) as cleanup_client: + for workspace_id in reversed(created_workspaces): + try: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: pane.swap and pane.break preserve visible focus for socket callers") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_surface_list_custom_titles.py b/tests_v2/test_surface_list_custom_titles.py new file mode 100644 index 00000000..3b93e635 --- /dev/null +++ b/tests_v2/test_surface_list_custom_titles.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Regression: surface.list and list-panels should return custom tab titles.""" + +from __future__ import annotations + +import glob +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, "--json", *args], + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}") + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output: {proc.stdout!r} ({exc})") + + +def main() -> int: + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + time.sleep(0.2) + + current_payload = client._call("surface.current", {"workspace_id": workspace_id}) or {} + surface_id = str(current_payload.get("surface_id") or "") + _must(bool(surface_id), f"surface.current returned no surface_id: {current_payload}") + + title = f"renamed-surface-{int(time.time() * 1000)}" + renamed = client._call( + "surface.action", + {"surface_id": surface_id, "action": "rename", "title": title}, + ) or {} + _must(str(renamed.get("title") or "") == title, f"surface.action rename failed: {renamed}") + + listed = client._call("surface.list", {"workspace_id": workspace_id}) or {} + row = next((item for item in listed.get("surfaces") or [] if str(item.get("id") or "") == surface_id), None) + _must(row is not None, f"surface.list missing renamed surface: {listed}") + _must(str(row.get("title") or "") == title, f"surface.list should return custom title {title!r}: {row}") + + cli_listed = _run_cli_json(cli, ["list-panels", "--workspace", workspace_id]) + cli_row = next((item for item in cli_listed.get("surfaces") or [] if str(item.get("title") or "") == title), None) + _must(cli_row is not None, f"list-panels missing renamed surface: {cli_listed}") + _must(str(cli_row.get("title") or "") == title, f"list-panels should return custom title {title!r}: {cli_row}") + finally: + if workspace_id: + with cmux(SOCKET_PATH) as cleanup_client: + try: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: surface.list and list-panels return custom surface titles") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())