Merge origin/main into pr-ssh-stack-main
This commit is contained in:
commit
965965c879
12 changed files with 1077 additions and 83 deletions
74
.github/workflows/nightly.yml
vendored
74
.github/workflows/nightly.yml
vendored
|
|
@ -13,10 +13,9 @@ on:
|
|||
|
||||
concurrency:
|
||||
group: nightly-build-${{ github.ref_name }}
|
||||
# Queue main pushes instead of hard-canceling older runs. The decide job
|
||||
# already coalesces to the current main HEAD, and we re-check HEAD before
|
||||
# publishing so stale queued runs exit cleanly instead of showing up red.
|
||||
cancel-in-progress: false
|
||||
# Only the newest nightly matters. Cancel older runs so a fresh main push
|
||||
# does not sit behind an outdated build that would be discarded anyway.
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@
|
|||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
|
||||
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
|
||||
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; };
|
||||
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
|
||||
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
|
||||
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
|
||||
|
|
@ -238,6 +239,7 @@
|
|||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
|
||||
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
|
||||
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
|
||||
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
|
||||
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
|
|
@ -472,6 +474,7 @@
|
|||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
|
||||
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
|
||||
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
|
||||
A5008380 /* BrowserFindJavaScriptTests.swift */,
|
||||
A5008382 /* CommandPaletteSearchEngineTests.swift */,
|
||||
);
|
||||
|
|
@ -711,6 +714,7 @@
|
|||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
|
||||
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */,
|
||||
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
|
||||
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
|
||||
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1368,6 +1368,7 @@ struct ContentView: View {
|
|||
@State private var workspaceHandoffGeneration: UInt64 = 0
|
||||
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
|
||||
@State private var didApplyUITestSidebarSelection = false
|
||||
@State private var workspaceHandoffReadyCheckTask: Task<Void, Never>?
|
||||
@State private var titlebarThemeGeneration: UInt64 = 0
|
||||
@State private var sidebarDraggedTabId: UUID?
|
||||
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import Darwin
|
|||
import Sentry
|
||||
import Bonsplit
|
||||
import IOSurface
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
#if os(macOS)
|
||||
func cmuxShouldUseTransparentBackgroundWindow() -> Bool {
|
||||
|
|
@ -75,6 +76,7 @@ private enum GhosttyPasteboardHelper {
|
|||
)
|
||||
private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text")
|
||||
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
|
||||
private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!)
|
||||
|
||||
static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? {
|
||||
switch location {
|
||||
|
|
@ -99,13 +101,35 @@ private enum GhosttyPasteboardHelper {
|
|||
return value
|
||||
}
|
||||
|
||||
return pasteboard.string(forType: utf8PlainTextType)
|
||||
if let value = pasteboard.string(forType: utf8PlainTextType) {
|
||||
return value
|
||||
}
|
||||
|
||||
if hasImageData(in: pasteboard),
|
||||
let html = pasteboard.string(forType: .html),
|
||||
htmlHasNoVisibleText(html) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) {
|
||||
return htmlText
|
||||
}
|
||||
|
||||
if let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) {
|
||||
return rtfText
|
||||
}
|
||||
|
||||
return attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd)
|
||||
}
|
||||
|
||||
static func hasString(for location: ghostty_clipboard_e) -> Bool {
|
||||
guard let pasteboard = pasteboard(for: location) else { return false }
|
||||
if let text = stringContents(from: pasteboard), !text.isEmpty { return true }
|
||||
return clipboardHasImageOnly()
|
||||
let types = pasteboard.types ?? []
|
||||
if types.contains(.fileURL) || types.contains(.string) || types.contains(utf8PlainTextType)
|
||||
|| types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) {
|
||||
return true
|
||||
}
|
||||
return hasImageData(in: pasteboard)
|
||||
}
|
||||
|
||||
static func writeString(_ string: String, to location: ghostty_clipboard_e) {
|
||||
|
|
@ -122,40 +146,184 @@ private enum GhosttyPasteboardHelper {
|
|||
return result
|
||||
}
|
||||
|
||||
private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
|
||||
private static func attributedStringContents(
|
||||
from pasteboard: NSPasteboard,
|
||||
type: NSPasteboard.PasteboardType,
|
||||
documentType: NSAttributedString.DocumentType
|
||||
) -> String? {
|
||||
let attributed = attributedString(
|
||||
from: pasteboard,
|
||||
type: type,
|
||||
documentType: documentType
|
||||
)
|
||||
|
||||
/// Quick check: does the clipboard have image data and no text?
|
||||
static func clipboardHasImageOnly() -> Bool {
|
||||
let pb = NSPasteboard.general
|
||||
let types = pb.types ?? []
|
||||
let hasText = types.contains(.string) || types.contains(.html)
|
||||
|| types.contains(.rtf) || types.contains(.rtfd)
|
||||
if hasText { return false }
|
||||
return types.contains(.tiff) || types.contains(.png)
|
||||
let sanitized = attributed?.string
|
||||
.split(separator: objectReplacementCharacter, omittingEmptySubsequences: false)
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard let sanitized, !sanitized.isEmpty else { return nil }
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/// When the clipboard contains only image data (no text/HTML), saves it as
|
||||
/// a temporary PNG file and returns the shell-escaped file path. Returns nil
|
||||
/// if the clipboard contains text or no image.
|
||||
static func saveClipboardImageIfNeeded() -> String? {
|
||||
let pb = NSPasteboard.general
|
||||
let types = pb.types ?? []
|
||||
private static func attributedString(
|
||||
from pasteboard: NSPasteboard,
|
||||
type: NSPasteboard.PasteboardType,
|
||||
documentType: NSAttributedString.DocumentType
|
||||
) -> NSAttributedString? {
|
||||
let data =
|
||||
pasteboard.data(forType: type)
|
||||
?? pasteboard.string(forType: type)?.data(using: .utf8)
|
||||
guard let data else { return nil }
|
||||
|
||||
// If pasteboard has text/HTML, this is a normal copy.
|
||||
let hasText = types.contains(.string) || types.contains(.html)
|
||||
|| types.contains(.rtf) || types.contains(.rtfd)
|
||||
if hasText { return nil }
|
||||
return try? NSAttributedString(
|
||||
data: data,
|
||||
options: [
|
||||
.documentType: documentType,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
],
|
||||
documentAttributes: nil
|
||||
)
|
||||
}
|
||||
|
||||
// Check for image types (TIFF from screenshots, PNG from some tools).
|
||||
guard types.contains(.tiff) || types.contains(.png) else { return nil }
|
||||
guard let image = NSImage(pasteboard: pb),
|
||||
let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
|
||||
private static func rtfdAttachmentImageRepresentation(
|
||||
in pasteboard: NSPasteboard
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
guard let attributed = attributedString(
|
||||
from: pasteboard,
|
||||
type: .rtfd,
|
||||
documentType: .rtfd
|
||||
) else { return nil }
|
||||
|
||||
guard pngData.count <= maxClipboardImageSize else {
|
||||
var result: (data: Data, fileExtension: String)?
|
||||
attributed.enumerateAttribute(
|
||||
.attachment,
|
||||
in: NSRange(location: 0, length: attributed.length)
|
||||
) { value, _, stop in
|
||||
guard let attachment = value as? NSTextAttachment else { return }
|
||||
|
||||
if let fileWrapper = attachment.fileWrapper,
|
||||
let data = fileWrapper.regularFileContents,
|
||||
let imageRepresentation = imageAttachmentRepresentation(
|
||||
data: data,
|
||||
preferredFilename: fileWrapper.preferredFilename
|
||||
) {
|
||||
result = imageRepresentation
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func imageAttachmentRepresentation(
|
||||
data: Data,
|
||||
preferredFilename: String?
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
let pathExtension =
|
||||
(preferredFilename as NSString?)?.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
?? ""
|
||||
if let type = !pathExtension.isEmpty ? UTType(filenameExtension: pathExtension) : nil,
|
||||
type.conforms(to: .image),
|
||||
let fileExtension = type.preferredFilenameExtension ?? nonEmpty(pathExtension) {
|
||||
return (data, fileExtension)
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let typeIdentifier = CGImageSourceGetType(imageSource) as String?,
|
||||
let type = UTType(typeIdentifier),
|
||||
type.conforms(to: .image),
|
||||
let fileExtension = type.preferredFilenameExtension else { return nil }
|
||||
return (data, fileExtension)
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func hasImageData(in pasteboard: NSPasteboard) -> Bool {
|
||||
let types = pasteboard.types ?? []
|
||||
if types.contains(.tiff) || types.contains(.png) {
|
||||
return true
|
||||
}
|
||||
|
||||
return types.contains { type in
|
||||
guard let utType = UTType(type.rawValue) else { return false }
|
||||
return utType.conforms(to: .image)
|
||||
}
|
||||
}
|
||||
|
||||
private static func directImageRepresentation(
|
||||
in pasteboard: NSPasteboard
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
if let pngData = pasteboard.data(forType: .png) {
|
||||
return (pngData, "png")
|
||||
}
|
||||
|
||||
for type in pasteboard.types ?? [] {
|
||||
guard type != .png,
|
||||
type != .tiff,
|
||||
let utType = UTType(type.rawValue),
|
||||
utType.conforms(to: .image),
|
||||
let imageData = pasteboard.data(forType: type),
|
||||
let fileExtension = utType.preferredFilenameExtension,
|
||||
!fileExtension.isEmpty else { continue }
|
||||
return (imageData, fileExtension)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func htmlHasNoVisibleText(_ html: String) -> Bool {
|
||||
let withoutComments = html.replacingOccurrences(
|
||||
of: "<!--[\\s\\S]*?-->",
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
let withoutTags = withoutComments.replacingOccurrences(
|
||||
of: "<[^>]+>",
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
let normalized = withoutTags
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return normalized.isEmpty
|
||||
}
|
||||
|
||||
/// When the clipboard contains only image data (or rich text that resolves to
|
||||
/// an attachment-only image), saves it as a temporary image file and returns the
|
||||
/// shell-escaped file path. Returns nil if the clipboard contains text or no image.
|
||||
static func saveClipboardImageIfNeeded(
|
||||
from pasteboard: NSPasteboard = .general,
|
||||
assumeNoText: Bool = false
|
||||
) -> String? {
|
||||
if !assumeNoText && stringContents(from: pasteboard) != nil { return nil }
|
||||
|
||||
let imageData: Data
|
||||
let fileExtension: String
|
||||
if let directImage = directImageRepresentation(in: pasteboard) {
|
||||
imageData = directImage.data
|
||||
fileExtension = directImage.fileExtension
|
||||
} else if let rtfdAttachment = rtfdAttachmentImageRepresentation(in: pasteboard) {
|
||||
imageData = rtfdAttachment.data
|
||||
fileExtension = rtfdAttachment.fileExtension
|
||||
} else {
|
||||
guard hasImageData(in: pasteboard),
|
||||
let image = NSImage(pasteboard: pasteboard),
|
||||
let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
|
||||
imageData = pngData
|
||||
fileExtension = "png"
|
||||
}
|
||||
|
||||
let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
|
||||
guard imageData.count <= maxClipboardImageSize else {
|
||||
#if DEBUG
|
||||
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.count)")
|
||||
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
|
@ -164,11 +332,11 @@ private enum GhosttyPasteboardHelper {
|
|||
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png"
|
||||
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
|
||||
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename)
|
||||
|
||||
do {
|
||||
try pngData.write(to: URL(fileURLWithPath: path))
|
||||
try imageData.write(to: URL(fileURLWithPath: path))
|
||||
} catch {
|
||||
#if DEBUG
|
||||
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
|
||||
|
|
@ -180,6 +348,16 @@ private enum GhosttyPasteboardHelper {
|
|||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> String? {
|
||||
GhosttyPasteboardHelper.stringContents(from: pasteboard)
|
||||
}
|
||||
|
||||
func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? {
|
||||
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard)
|
||||
}
|
||||
#endif
|
||||
|
||||
enum TerminalOpenURLTarget: Equatable {
|
||||
case embeddedBrowser(URL)
|
||||
case external(URL)
|
||||
|
|
@ -877,7 +1055,11 @@ class GhosttyApp {
|
|||
|
||||
// When clipboard has only image data (e.g. screenshot), save as temp
|
||||
// PNG and paste the file path so CLI tools can receive images.
|
||||
if value.isEmpty, let imagePath = GhosttyPasteboardHelper.saveClipboardImageIfNeeded() {
|
||||
if value.isEmpty,
|
||||
let imagePath = pasteboard.flatMap({
|
||||
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: $0, assumeNoText: true)
|
||||
})
|
||||
{
|
||||
value = imagePath
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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("<p>Hello <strong>world</strong></p>", forType: .html)
|
||||
|
||||
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world")
|
||||
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
}
|
||||
|
||||
func testImageHTMLClipboardFallsBackToImagePath() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString("<meta charset='utf-8'><img src=\"https://example.com/keyboard.png\">", forType: .html)
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.red.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
let tiffData = try XCTUnwrap(image.tiffRepresentation)
|
||||
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
|
||||
let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
|
||||
pasteboard.setData(pngData, forType: .png)
|
||||
|
||||
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
|
||||
|
||||
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
defer { try? FileManager.default.removeItem(atPath: imagePath) }
|
||||
|
||||
XCTAssertTrue(imagePath.hasSuffix(".png"))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
|
||||
}
|
||||
|
||||
func testImageHTMLClipboardWithVisibleTextPrefersText() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString("<p>Hello <img src=\"https://example.com/keyboard.png\"></p>", forType: .html)
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.blue.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
let tiffData = try XCTUnwrap(image.tiffRepresentation)
|
||||
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
|
||||
let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
|
||||
pasteboard.setData(pngData, forType: .png)
|
||||
|
||||
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
|
||||
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
}
|
||||
|
||||
func testJPEGClipboardFallsBackToImagePath() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.green.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
|
||||
let tiffData = try XCTUnwrap(image.tiffRepresentation)
|
||||
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
|
||||
let jpegData = try XCTUnwrap(
|
||||
bitmap.representation(
|
||||
using: .jpeg,
|
||||
properties: [.compressionFactor: 1.0]
|
||||
)
|
||||
)
|
||||
pasteboard.setData(
|
||||
jpegData,
|
||||
forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier)
|
||||
)
|
||||
|
||||
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
defer { try? FileManager.default.removeItem(atPath: imagePath) }
|
||||
|
||||
XCTAssertTrue(imagePath.hasSuffix(".jpeg"))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
|
||||
}
|
||||
|
||||
func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.orange.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = image
|
||||
let attributed = NSAttributedString(attachment: attachment)
|
||||
let data = try attributed.data(
|
||||
from: NSRange(location: 0, length: attributed.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
pasteboard.setData(data, forType: .rtfd)
|
||||
|
||||
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
|
||||
|
||||
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
defer { try? FileManager.default.removeItem(atPath: imagePath) }
|
||||
|
||||
XCTAssertTrue(imagePath.hasSuffix(".tiff"))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
|
||||
}
|
||||
|
||||
func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
|
||||
let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8))
|
||||
wrapper.preferredFilename = "note.txt"
|
||||
|
||||
let attachment = NSTextAttachment(fileWrapper: wrapper)
|
||||
let attributed = NSAttributedString(attachment: attachment)
|
||||
let data = try attributed.data(
|
||||
from: NSRange(location: 0, length: attributed.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
pasteboard.setData(data, forType: .rtfd)
|
||||
|
||||
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
|
||||
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
}
|
||||
|
||||
func testRTFDClipboardWithVisibleTextPrefersText() throws {
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
|
||||
let image = NSImage(size: NSSize(width: 1, height: 1))
|
||||
image.lockFocus()
|
||||
NSColor.purple.setFill()
|
||||
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
|
||||
image.unlockFocus()
|
||||
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = image
|
||||
|
||||
let attributed = NSMutableAttributedString(string: "Hello ")
|
||||
attributed.append(NSAttributedString(attachment: attachment))
|
||||
let data = try attributed.data(
|
||||
from: NSRange(location: 0, length: attributed.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
pasteboard.setData(data, forType: .rtfd)
|
||||
|
||||
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
|
||||
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegateWindowContextRoutingTests: XCTestCase {
|
||||
private func makeMainWindow(id: UUID) -> NSWindow {
|
||||
|
|
|
|||
282
cmuxTests/WorkspaceStressProfileTests.swift
Normal file
282
cmuxTests/WorkspaceStressProfileTests.swift
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceStressProfileTests: XCTestCase {
|
||||
private struct StressConfig {
|
||||
let workspaceCount: Int
|
||||
let tabsPerWorkspace: Int
|
||||
let switchPasses: Int
|
||||
let createP95BudgetMs: Double?
|
||||
let switchP95BudgetMs: Double?
|
||||
|
||||
static func current(environment: [String: String] = ProcessInfo.processInfo.environment) -> StressConfig {
|
||||
StressConfig(
|
||||
workspaceCount: parseInt(environment["CMUX_WORKSPACE_STRESS_WORKSPACES"], default: 48, minimum: 2),
|
||||
tabsPerWorkspace: parseInt(environment["CMUX_WORKSPACE_STRESS_TABS_PER_WORKSPACE"], default: 10, minimum: 1),
|
||||
switchPasses: parseInt(environment["CMUX_WORKSPACE_STRESS_SWITCH_PASSES"], default: 6, minimum: 1),
|
||||
createP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_CREATE_P95_BUDGET_MS"]),
|
||||
switchP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_SWITCH_P95_BUDGET_MS"])
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseInt(_ value: String?, default defaultValue: Int, minimum: Int) -> Int {
|
||||
guard let value, let parsed = Int(value) else { return defaultValue }
|
||||
return max(minimum, parsed)
|
||||
}
|
||||
|
||||
private static func parseDouble(_ value: String?) -> Double? {
|
||||
guard let value, let parsed = Double(value) else { return nil }
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimedSample {
|
||||
let label: String
|
||||
let elapsedMs: Double
|
||||
}
|
||||
|
||||
private struct TimingSummary {
|
||||
let count: Int
|
||||
let averageMs: Double
|
||||
let medianMs: Double
|
||||
let p95Ms: Double
|
||||
let maxMs: Double
|
||||
let totalMs: Double
|
||||
|
||||
init(samples: [TimedSample]) {
|
||||
let sorted = samples.map(\.elapsedMs).sorted()
|
||||
count = sorted.count
|
||||
totalMs = sorted.reduce(0, +)
|
||||
averageMs = count > 0 ? totalMs / Double(count) : 0
|
||||
medianMs = Self.percentile(0.50, in: sorted)
|
||||
p95Ms = Self.percentile(0.95, in: sorted)
|
||||
maxMs = sorted.last ?? 0
|
||||
}
|
||||
|
||||
private static func percentile(_ percentile: Double, in sortedValues: [Double]) -> Double {
|
||||
guard !sortedValues.isEmpty else { return 0 }
|
||||
let clamped = min(max(percentile, 0), 1)
|
||||
let index = Int((Double(sortedValues.count - 1) * clamped).rounded(.up))
|
||||
return sortedValues[min(sortedValues.count - 1, max(0, index))]
|
||||
}
|
||||
}
|
||||
|
||||
func testWorkspaceCreationAndSwitchingStressProfile() {
|
||||
let config = StressConfig.current()
|
||||
let welcomeWasShown = UserDefaults.standard.object(forKey: WelcomeSettings.shownKey)
|
||||
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
||||
defer {
|
||||
if let welcomeWasShown {
|
||||
UserDefaults.standard.set(welcomeWasShown, forKey: WelcomeSettings.shownKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: WelcomeSettings.shownKey)
|
||||
}
|
||||
}
|
||||
|
||||
var creationSamples: [TimedSample] = []
|
||||
var populationSamples: [TimedSample] = []
|
||||
var switchSamples: [TimedSample] = []
|
||||
var switchDispatchSamples: [TimedSample] = []
|
||||
var switchFirstDrainSamples: [TimedSample] = []
|
||||
var switchUnfocusSamples: [TimedSample] = []
|
||||
var switchSecondDrainSamples: [TimedSample] = []
|
||||
|
||||
let manager = timed("workspace-000-create", collectInto: &creationSamples) {
|
||||
TabManager()
|
||||
}
|
||||
|
||||
guard let bootstrapWorkspace = manager.selectedWorkspace else {
|
||||
XCTFail("Expected bootstrap workspace")
|
||||
return
|
||||
}
|
||||
|
||||
timed("workspace-000-populate", collectInto: &populationSamples) {
|
||||
populate(workspace: bootstrapWorkspace, tabsPerWorkspace: config.tabsPerWorkspace)
|
||||
}
|
||||
settleWorkspaceSelection(manager)
|
||||
|
||||
for workspaceIndex in 1..<config.workspaceCount {
|
||||
let workspace = timed("workspace-\(label(for: workspaceIndex))-create", collectInto: &creationSamples) {
|
||||
manager.addWorkspace(
|
||||
select: true,
|
||||
eagerLoadTerminal: false,
|
||||
autoWelcomeIfNeeded: false
|
||||
)
|
||||
}
|
||||
|
||||
settleWorkspaceSelection(manager)
|
||||
|
||||
timed("workspace-\(label(for: workspaceIndex))-populate", collectInto: &populationSamples) {
|
||||
populate(workspace: workspace, tabsPerWorkspace: config.tabsPerWorkspace)
|
||||
}
|
||||
settleWorkspaceSelection(manager)
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.tabs.count, config.workspaceCount)
|
||||
XCTAssertTrue(manager.tabs.allSatisfy { $0.panels.count == config.tabsPerWorkspace })
|
||||
|
||||
for pass in 0..<config.switchPasses {
|
||||
for switchIndex in 0..<manager.tabs.count {
|
||||
timed("pass-\(label(for: pass))-next-\(label(for: switchIndex))", collectInto: &switchSamples) {
|
||||
timed("pass-\(label(for: pass))-next-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
|
||||
manager.selectNextTab()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-next-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-next-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
|
||||
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
|
||||
}
|
||||
timed("pass-\(label(for: pass))-next-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for switchIndex in 0..<manager.tabs.count {
|
||||
timed("pass-\(label(for: pass))-prev-\(label(for: switchIndex))", collectInto: &switchSamples) {
|
||||
timed("pass-\(label(for: pass))-prev-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
|
||||
manager.selectPreviousTab()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-prev-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-prev-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
|
||||
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
|
||||
}
|
||||
timed("pass-\(label(for: pass))-prev-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertNotNil(manager.selectedWorkspace)
|
||||
|
||||
let creationSummary = TimingSummary(samples: creationSamples)
|
||||
let populationSummary = TimingSummary(samples: populationSamples)
|
||||
let switchSummary = TimingSummary(samples: switchSamples)
|
||||
let switchDispatchSummary = TimingSummary(samples: switchDispatchSamples)
|
||||
let switchFirstDrainSummary = TimingSummary(samples: switchFirstDrainSamples)
|
||||
let switchUnfocusSummary = TimingSummary(samples: switchUnfocusSamples)
|
||||
let switchSecondDrainSummary = TimingSummary(samples: switchSecondDrainSamples)
|
||||
|
||||
let report = [
|
||||
"Workspace stress config workspaces=\(config.workspaceCount) tabsPerWorkspace=\(config.tabsPerWorkspace) switchPasses=\(config.switchPasses)",
|
||||
reportLine(title: "create", summary: creationSummary, slowest: slowest(creationSamples)),
|
||||
reportLine(title: "populate", summary: populationSummary, slowest: slowest(populationSamples)),
|
||||
reportLine(title: "switch", summary: switchSummary, slowest: slowest(switchSamples)),
|
||||
reportLine(title: "switch.dispatch", summary: switchDispatchSummary, slowest: slowest(switchDispatchSamples)),
|
||||
reportLine(title: "switch.drain1", summary: switchFirstDrainSummary, slowest: slowest(switchFirstDrainSamples)),
|
||||
reportLine(title: "switch.unfocus", summary: switchUnfocusSummary, slowest: slowest(switchUnfocusSamples)),
|
||||
reportLine(title: "switch.drain2", summary: switchSecondDrainSummary, slowest: slowest(switchSecondDrainSamples))
|
||||
].joined(separator: "\n")
|
||||
|
||||
print(report)
|
||||
let attachment = XCTAttachment(string: report)
|
||||
attachment.name = "workspace-stress-profile"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
|
||||
if let createP95BudgetMs = config.createP95BudgetMs {
|
||||
XCTAssertLessThanOrEqual(
|
||||
creationSummary.p95Ms,
|
||||
createP95BudgetMs,
|
||||
"Workspace creation p95 exceeded budget"
|
||||
)
|
||||
}
|
||||
if let switchP95BudgetMs = config.switchP95BudgetMs {
|
||||
XCTAssertLessThanOrEqual(
|
||||
switchSummary.p95Ms,
|
||||
switchP95BudgetMs,
|
||||
"Workspace switch p95 exceeded budget"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func populate(workspace: Workspace, tabsPerWorkspace: Int) {
|
||||
guard tabsPerWorkspace > 0 else { return }
|
||||
while workspace.panels.count < tabsPerWorkspace {
|
||||
let created = workspace.newTerminalSurfaceInFocusedPane(focus: false)
|
||||
guard created != nil else {
|
||||
XCTFail("Expected terminal tab creation to succeed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func settleWorkspaceSelection(_ manager: TabManager) {
|
||||
drainMainQueue()
|
||||
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
|
||||
drainMainQueue()
|
||||
}
|
||||
|
||||
private func drainMainQueue() {
|
||||
let deadline = Date(timeIntervalSinceNow: 1.0)
|
||||
var drained = false
|
||||
DispatchQueue.main.async {
|
||||
drained = true
|
||||
}
|
||||
while !drained {
|
||||
if Date() >= deadline {
|
||||
XCTFail("Timed out draining main queue")
|
||||
return
|
||||
}
|
||||
let sliceDeadline = min(deadline, Date(timeIntervalSinceNow: 0.001))
|
||||
_ = RunLoop.main.run(mode: .default, before: sliceDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func timed<T>(
|
||||
_ label: String,
|
||||
collectInto samples: inout [TimedSample],
|
||||
operation: () -> T
|
||||
) -> T {
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
let value = operation()
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
|
||||
samples.append(TimedSample(label: label, elapsedMs: elapsedMs))
|
||||
return value
|
||||
}
|
||||
|
||||
private func slowest(_ samples: [TimedSample], count: Int = 5) -> String {
|
||||
samples
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.elapsedMs == rhs.elapsedMs {
|
||||
return lhs.label < rhs.label
|
||||
}
|
||||
return lhs.elapsedMs > rhs.elapsedMs
|
||||
}
|
||||
.prefix(count)
|
||||
.map { "\($0.label)=\(formatMs($0.elapsedMs))" }
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func reportLine(title: String, summary: TimingSummary, slowest: String) -> String {
|
||||
[
|
||||
"\(title):",
|
||||
"count=\(summary.count)",
|
||||
"avg=\(formatMs(summary.averageMs))",
|
||||
"median=\(formatMs(summary.medianMs))",
|
||||
"p95=\(formatMs(summary.p95Ms))",
|
||||
"max=\(formatMs(summary.maxMs))",
|
||||
"total=\(formatMs(summary.totalMs))",
|
||||
"slowest=[\(slowest)]"
|
||||
].joined(separator: " ")
|
||||
}
|
||||
|
||||
private func formatMs(_ value: Double) -> String {
|
||||
String(format: "%.2fms", value)
|
||||
}
|
||||
|
||||
private func label(for index: Int) -> String {
|
||||
String(format: "%03d", index)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933
|
|||
c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df
|
||||
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
|
||||
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30
|
||||
404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd
|
||||
|
|
|
|||
96
tests_v2/test_pane_break_swap_preserve_focus.py
Normal file
96
tests_v2/test_pane_break_swap_preserve_focus.py
Normal file
|
|
@ -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())
|
||||
103
tests_v2/test_surface_list_custom_titles.py
Normal file
103
tests_v2/test_surface_list_custom_titles.py
Normal file
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue