Harden drag overlay routing and add terminal overlay regression probes
This commit is contained in:
parent
9388358914
commit
a5c7600458
8 changed files with 329 additions and 20 deletions
|
|
@ -70,12 +70,16 @@ Before launching a new tagged run, clean up any older tags you started in this s
|
|||
|
||||
## Debug event log
|
||||
|
||||
All debug events (keys, mouse, focus, splits, tabs) go to a single unified log in DEBUG builds:
|
||||
All debug events (keys, mouse, focus, splits, tabs) go to a unified log in DEBUG builds:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/cmux-debug.log
|
||||
tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug.log)"
|
||||
```
|
||||
|
||||
- Untagged Debug app: `/tmp/cmux-debug.log`
|
||||
- Tagged Debug app (`./scripts/reload.sh --tag <tag>`): `/tmp/cmux-debug-<tag>.log`
|
||||
- `reload.sh` writes the current path to `/tmp/cmux-last-debug-log-path`
|
||||
|
||||
- Implementation: `vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift`
|
||||
- Free function `dlog("message")` — logs with timestamp and appends to file in real time
|
||||
- Entire file is `#if DEBUG`; all call sites must be wrapped in `#if DEBUG` / `#endif`
|
||||
|
|
|
|||
|
|
@ -184,7 +184,10 @@ enum DragOverlayRoutingPolicy {
|
|||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
hasLocalDraggingSource: Bool
|
||||
) -> Bool {
|
||||
guard !hasLocalDraggingSource else { return false }
|
||||
// Local file drags (e.g. in-app draggable folder views) are valid drop
|
||||
// inputs; rely on explicit non-file drag types below to avoid hijacking
|
||||
// Bonsplit/sidebar drags.
|
||||
_ = hasLocalDraggingSource
|
||||
guard hasFileURL(pasteboardTypes) else { return false }
|
||||
|
||||
// Prefer explicit non-file drag types so stale fileURL entries cannot hijack
|
||||
|
|
@ -252,6 +255,11 @@ enum DragOverlayRoutingPolicy {
|
|||
switch eventType {
|
||||
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
||||
return true
|
||||
case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp:
|
||||
// During tab drags AppKit can still query hit-test routing with mouse
|
||||
// down/up events. If we reject these, terminal portal layers may steal
|
||||
// the initial drop routing path and suppress pane drop indicators.
|
||||
return true
|
||||
case .flagsChanged:
|
||||
// Real tab drags can briefly report flagsChanged while modifiers
|
||||
// are sampled; still treat as drag-routing context.
|
||||
|
|
|
|||
|
|
@ -2927,13 +2927,16 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
private var lastSentRow: Int?
|
||||
private var isActive = true
|
||||
private var activeDropZone: DropZone?
|
||||
private var pendingDropZone: DropZone?
|
||||
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
|
||||
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
|
||||
#if DEBUG
|
||||
private var lastDropZoneOverlayLogSignature: String?
|
||||
private static var flashCounts: [UUID: Int] = [:]
|
||||
private static var drawCounts: [UUID: Int] = [:]
|
||||
private static var lastDrawTimes: [UUID: CFTimeInterval] = [:]
|
||||
private static var presentCounts: [UUID: Int] = [:]
|
||||
private static var dropOverlayShowCounts: [UUID: Int] = [:]
|
||||
private static var lastPresentTimes: [UUID: CFTimeInterval] = [:]
|
||||
private static var lastContentsKeys: [UUID: String] = [:]
|
||||
|
||||
|
|
@ -2998,6 +3001,38 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key)
|
||||
}
|
||||
|
||||
private func recordDropOverlayShowAnimation() {
|
||||
guard let surfaceId = surfaceView.terminalSurface?.id else { return }
|
||||
Self.dropOverlayShowCounts[surfaceId, default: 0] += 1
|
||||
}
|
||||
|
||||
func debugProbeDropOverlayAnimation(useDeferredPath: Bool) -> (before: Int, after: Int, bounds: CGSize) {
|
||||
guard let surfaceId = surfaceView.terminalSurface?.id else {
|
||||
return (0, 0, bounds.size)
|
||||
}
|
||||
|
||||
let before = Self.dropOverlayShowCounts[surfaceId, default: 0]
|
||||
|
||||
// Reset to a hidden baseline so each probe exercises an initial-show transition.
|
||||
dropZoneOverlayAnimationGeneration &+= 1
|
||||
activeDropZone = nil
|
||||
pendingDropZone = nil
|
||||
dropZoneOverlayView.layer?.removeAllAnimations()
|
||||
dropZoneOverlayView.isHidden = true
|
||||
dropZoneOverlayView.alphaValue = 1
|
||||
|
||||
if useDeferredPath {
|
||||
pendingDropZone = .left
|
||||
synchronizeGeometryAndContent()
|
||||
} else {
|
||||
setDropZoneOverlay(zone: .left)
|
||||
}
|
||||
|
||||
let after = Self.dropOverlayShowCounts[surfaceId, default: 0]
|
||||
setDropZoneOverlay(zone: nil)
|
||||
return (before, after, bounds.size)
|
||||
}
|
||||
|
||||
var debugSurfaceId: UUID? {
|
||||
surfaceView.terminalSurface?.id
|
||||
}
|
||||
|
|
@ -3182,6 +3217,18 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
if let zone = activeDropZone {
|
||||
dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size)
|
||||
}
|
||||
if let pending = pendingDropZone,
|
||||
bounds.width > 2,
|
||||
bounds.height > 2 {
|
||||
pendingDropZone = nil
|
||||
#if DEBUG
|
||||
let frame = dropZoneOverlayFrame(for: pending, in: bounds.size)
|
||||
logDropZoneOverlay(event: "flushPending", zone: pending, frame: frame)
|
||||
#endif
|
||||
// Reuse the normal show/update path so deferred overlays get the
|
||||
// same initial animation as direct drop-zone activation.
|
||||
setDropZoneOverlay(zone: pending)
|
||||
}
|
||||
notificationRingOverlayView.frame = bounds
|
||||
flashOverlayView.frame = bounds
|
||||
updateNotificationRingPath()
|
||||
|
|
@ -3288,12 +3335,26 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
return
|
||||
}
|
||||
|
||||
if let zone, (bounds.width <= 2 || bounds.height <= 2) {
|
||||
pendingDropZone = zone
|
||||
#if DEBUG
|
||||
logDropZoneOverlay(event: "deferZeroBounds", zone: zone, frame: nil)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
let previousZone = activeDropZone
|
||||
activeDropZone = zone
|
||||
pendingDropZone = nil
|
||||
|
||||
let previousFrame = dropZoneOverlayView.frame
|
||||
|
||||
if let zone {
|
||||
#if DEBUG
|
||||
if window == nil {
|
||||
logDropZoneOverlay(event: "showNoWindow", zone: zone, frame: nil)
|
||||
}
|
||||
#endif
|
||||
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
|
||||
let isSameFrame = Self.rectApproximatelyEqual(previousFrame, targetFrame)
|
||||
let needsFrameUpdate = !isSameFrame
|
||||
|
|
@ -3310,15 +3371,32 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
dropZoneOverlayView.frame = targetFrame
|
||||
dropZoneOverlayView.alphaValue = 0
|
||||
dropZoneOverlayView.isHidden = false
|
||||
#if DEBUG
|
||||
recordDropOverlayShowAnimation()
|
||||
#endif
|
||||
#if DEBUG
|
||||
logDropZoneOverlay(event: "show", zone: zone, frame: targetFrame)
|
||||
#endif
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
dropZoneOverlayView.animator().alphaValue = 1
|
||||
} completionHandler: { [weak self] in
|
||||
#if DEBUG
|
||||
guard let self else { return }
|
||||
guard self.activeDropZone == zone else { return }
|
||||
self.logDropZoneOverlay(event: "showComplete", zone: zone, frame: targetFrame)
|
||||
#endif
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if needsFrameUpdate || zoneChanged {
|
||||
logDropZoneOverlay(event: "update", zone: zone, frame: targetFrame)
|
||||
}
|
||||
#endif
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
|
|
@ -3334,6 +3412,9 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
dropZoneOverlayAnimationGeneration &+= 1
|
||||
let animationGeneration = dropZoneOverlayAnimationGeneration
|
||||
dropZoneOverlayView.layer?.removeAllAnimations()
|
||||
#if DEBUG
|
||||
logDropZoneOverlay(event: "hide", zone: nil, frame: nil)
|
||||
#endif
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.14
|
||||
|
|
@ -3345,10 +3426,37 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
guard self.activeDropZone == nil else { return }
|
||||
self.dropZoneOverlayView.isHidden = true
|
||||
self.dropZoneOverlayView.alphaValue = 1
|
||||
#if DEBUG
|
||||
self.logDropZoneOverlay(event: "hideComplete", zone: nil, frame: nil)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func logDropZoneOverlay(event: String, zone: DropZone?, frame: CGRect?) {
|
||||
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
let zoneText = zone.map { String(describing: $0) } ?? "none"
|
||||
let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height)
|
||||
let frameText: String
|
||||
if let frame {
|
||||
frameText = String(
|
||||
format: "%.1f,%.1f %.1fx%.1f",
|
||||
frame.origin.x, frame.origin.y, frame.width, frame.height
|
||||
)
|
||||
} else {
|
||||
frameText = "-"
|
||||
}
|
||||
let signature = "\(event)|\(surface)|\(zoneText)|\(boundsText)|\(frameText)|\(dropZoneOverlayView.isHidden ? 1 : 0)"
|
||||
guard lastDropZoneOverlayLogSignature != signature else { return }
|
||||
lastDropZoneOverlayLogSignature = signature
|
||||
dlog(
|
||||
"terminal.dropOverlay event=\(event) surface=\(surface) zone=\(zoneText) " +
|
||||
"hidden=\(dropZoneOverlayView.isHidden ? 1 : 0) bounds=\(boundsText) frame=\(frameText)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
func triggerFlash() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
|
@ -4179,6 +4287,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
var desiredShowsUnreadNotificationRing: Bool = false
|
||||
var desiredPortalZPriority: Int = 0
|
||||
var lastBoundHostId: ObjectIdentifier?
|
||||
var lastPaneDropZone: DropZone?
|
||||
weak var hostedView: GhosttySurfaceScrollView?
|
||||
}
|
||||
|
||||
|
|
@ -4238,7 +4347,27 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
hostedView.setNotificationRing(visible: showsUnreadNotificationRing)
|
||||
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
|
||||
hostedView.setTriggerFlashHandler(onTriggerFlash)
|
||||
hostedView.setDropZoneOverlay(zone: paneDropZone)
|
||||
let forwardedDropZone = isVisibleInUI ? paneDropZone : nil
|
||||
#if DEBUG
|
||||
if coordinator.lastPaneDropZone != paneDropZone {
|
||||
let oldZone = coordinator.lastPaneDropZone.map { String(describing: $0) } ?? "none"
|
||||
let newZone = paneDropZone.map { String(describing: $0) } ?? "none"
|
||||
dlog(
|
||||
"terminal.paneDropZone surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"old=\(oldZone) new=\(newZone) " +
|
||||
"active=\(isActive ? 1 : 0) visible=\(isVisibleInUI ? 1 : 0) " +
|
||||
"inWindow=\(hostedView.window != nil ? 1 : 0)"
|
||||
)
|
||||
coordinator.lastPaneDropZone = paneDropZone
|
||||
}
|
||||
if paneDropZone != nil, !isVisibleInUI {
|
||||
dlog(
|
||||
"terminal.paneDropZone.suppress surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"requested=\(String(describing: paneDropZone!)) visible=0 active=\(isActive ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
hostedView.setDropZoneOverlay(zone: forwardedDropZone)
|
||||
|
||||
coordinator.attachGeneration += 1
|
||||
let generation = coordinator.attachGeneration
|
||||
|
|
|
|||
|
|
@ -511,6 +511,9 @@ class TerminalController {
|
|||
case "sidebar_overlay_gate":
|
||||
return sidebarOverlayGate(args)
|
||||
|
||||
case "terminal_drop_overlay_probe":
|
||||
return terminalDropOverlayProbe(args)
|
||||
|
||||
case "activate_app":
|
||||
return activateApp()
|
||||
|
||||
|
|
@ -7147,6 +7150,7 @@ class TerminalController {
|
|||
overlay_drop_gate [external|local] - Return true/false if file-drop overlay would capture drag destination routing (test-only)
|
||||
portal_hit_gate <event|none> - Return true/false if terminal portal should pass hit-testing to SwiftUI drag targets (test-only)
|
||||
sidebar_overlay_gate [active|inactive] - Return true/false if sidebar outside-drop overlay would capture (test-only)
|
||||
terminal_drop_overlay_probe [deferred|direct] - Trigger focused terminal drop-overlay show path and report animation counts (test-only)
|
||||
activate_app - Bring app + main window to front (test-only)
|
||||
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
|
||||
read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
|
||||
|
|
@ -7514,6 +7518,52 @@ class TerminalController {
|
|||
return shouldCapture ? "true" : "false"
|
||||
}
|
||||
|
||||
private func terminalDropOverlayProbe(_ args: String) -> String {
|
||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let useDeferredPath: Bool
|
||||
switch token {
|
||||
case "", "deferred":
|
||||
useDeferredPath = true
|
||||
case "direct":
|
||||
useDeferredPath = false
|
||||
default:
|
||||
return "ERROR: Usage: terminal_drop_overlay_probe [deferred|direct]"
|
||||
}
|
||||
|
||||
var result = "ERROR: No selected workspace"
|
||||
DispatchQueue.main.sync {
|
||||
guard let selectedId = tabManager.selectedTabId,
|
||||
let workspace = tabManager.tabs.first(where: { $0.id == selectedId }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let terminalPanel = workspace.focusedTerminalPanel
|
||||
?? orderedPanels(in: workspace).compactMap { $0 as? TerminalPanel }.first
|
||||
guard let terminalPanel else {
|
||||
result = "ERROR: No terminal panel available"
|
||||
return
|
||||
}
|
||||
|
||||
let probe = terminalPanel.hostedView.debugProbeDropOverlayAnimation(
|
||||
useDeferredPath: useDeferredPath
|
||||
)
|
||||
let animated = probe.after > probe.before
|
||||
let mode = useDeferredPath ? "deferred" : "direct"
|
||||
result = String(
|
||||
format: "OK mode=%@ animated=%d before=%d after=%d bounds=%.1fx%.1f",
|
||||
mode,
|
||||
animated ? 1 : 0,
|
||||
probe.before,
|
||||
probe.after,
|
||||
probe.bounds.width,
|
||||
probe.bounds.height
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func parseOverlayEventType(_ token: String) -> (isKnown: Bool, eventType: NSEvent.EventType?) {
|
||||
switch token {
|
||||
case "leftmousedragged":
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
: FileManager.default.homeDirectoryForCurrentUser.path
|
||||
|
||||
// Configure bonsplit with keepAllAlive to preserve terminal state
|
||||
// Disable split animations for instant response
|
||||
// and keep split entry instantaneous.
|
||||
let appearance = Self.bonsplitAppearance(from: GhosttyConfig.load())
|
||||
let config = BonsplitConfiguration(
|
||||
allowSplits: true,
|
||||
|
|
@ -1447,10 +1447,28 @@ extension Workspace: BonsplitDelegate {
|
|||
|
||||
func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) {
|
||||
#if DEBUG
|
||||
let panelKindForTab: (TabID) -> String = { tabId in
|
||||
guard let panelId = self.panelIdFromSurfaceId(tabId),
|
||||
let panel = self.panels[panelId] else { return "placeholder" }
|
||||
if panel is TerminalPanel { return "terminal" }
|
||||
if panel is BrowserPanel { return "browser" }
|
||||
return String(describing: type(of: panel))
|
||||
}
|
||||
let paneKindSummary: (PaneID) -> String = { paneId in
|
||||
let tabs = controller.tabs(inPane: paneId)
|
||||
guard !tabs.isEmpty else { return "-" }
|
||||
return tabs.map { tab in
|
||||
String(panelKindForTab(tab.id).prefix(1))
|
||||
}.joined(separator: ",")
|
||||
}
|
||||
let originalSelectedKind = controller.selectedTab(inPane: originalPane).map { panelKindForTab($0.id) } ?? "none"
|
||||
let newSelectedKind = controller.selectedTab(inPane: newPane).map { panelKindForTab($0.id) } ?? "none"
|
||||
dlog(
|
||||
"split.didSplit original=\(originalPane.id.uuidString.prefix(5)) new=\(newPane.id.uuidString.prefix(5)) " +
|
||||
"orientation=\(orientation) programmatic=\(isProgrammaticSplit ? 1 : 0) " +
|
||||
"originalTabs=\(controller.tabs(inPane: originalPane).count) newTabs=\(controller.tabs(inPane: newPane).count)"
|
||||
"originalTabs=\(controller.tabs(inPane: originalPane).count) newTabs=\(controller.tabs(inPane: newPane).count) " +
|
||||
"originalSelected=\(originalSelectedKind) newSelected=\(newSelectedKind) " +
|
||||
"originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]"
|
||||
)
|
||||
#endif
|
||||
// Only auto-create a terminal if the split came from bonsplit UI.
|
||||
|
|
@ -1475,7 +1493,8 @@ extension Workspace: BonsplitDelegate {
|
|||
dlog(
|
||||
"split.didSplit.drag original=\(originalPane.id.uuidString.prefix(5)) " +
|
||||
"new=\(newPane.id.uuidString.prefix(5)) originalTabs=\(originalTabs.count) " +
|
||||
"newTabs=\(controller.tabs(inPane: newPane).count) hasRealSurface=\(hasRealSurface ? 1 : 0)"
|
||||
"newTabs=\(controller.tabs(inPane: newPane).count) hasRealSurface=\(hasRealSurface ? 1 : 0) " +
|
||||
"originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]"
|
||||
)
|
||||
#endif
|
||||
if !hasRealSurface {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ NAME_SET=0
|
|||
BUNDLE_SET=0
|
||||
DERIVED_SET=0
|
||||
TAG=""
|
||||
CMUX_DEBUG_LOG=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
|
|
@ -82,12 +83,14 @@ print_tag_cleanup_reminder() {
|
|||
for tag in "${stale_tags[@]}"; do
|
||||
echo " pkill -f \"cmux DEV ${tag}.app/Contents/MacOS/cmux DEV\""
|
||||
echo " rm -rf \"/tmp/cmux-${tag}\" \"/tmp/cmux-debug-${tag}.sock\""
|
||||
echo " rm -f \"/tmp/cmux-debug-${tag}.log\""
|
||||
echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${tag}.sock\""
|
||||
done
|
||||
fi
|
||||
echo "After you verify current tag, cleanup command:"
|
||||
echo " pkill -f \"cmux DEV ${current_slug}.app/Contents/MacOS/cmux DEV\""
|
||||
echo " rm -rf \"/tmp/cmux-${current_slug}\" \"/tmp/cmux-debug-${current_slug}.sock\""
|
||||
echo " rm -f \"/tmp/cmux-debug-${current_slug}.log\""
|
||||
echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${current_slug}.sock\""
|
||||
}
|
||||
|
||||
|
|
@ -243,12 +246,16 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then
|
|||
APP_SUPPORT_DIR="$HOME/Library/Application Support/cmux"
|
||||
CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-dev-${TAG_SLUG}.sock"
|
||||
CMUX_SOCKET="/tmp/cmux-debug-${TAG_SLUG}.sock"
|
||||
CMUX_DEBUG_LOG="/tmp/cmux-debug-${TAG_SLUG}.log"
|
||||
echo "$CMUX_SOCKET" > /tmp/cmux-last-socket-path || true
|
||||
echo "$CMUX_DEBUG_LOG" > /tmp/cmux-last-debug-log-path || true
|
||||
/usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXD_UNIX_PATH string \"${CMUXD_SOCKET}\"" "$INFO_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_PATH \"${CMUX_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_DEBUG_LOG \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_DEBUG_LOG string \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST"
|
||||
if [[ -S "$CMUXD_SOCKET" ]]; then
|
||||
for PID in $(lsof -t "$CMUXD_SOCKET" 2>/dev/null); do
|
||||
kill "$PID" 2>/dev/null || true
|
||||
|
|
@ -295,6 +302,7 @@ OPEN_CLEAN_ENV=(
|
|||
-u CMUX_PANEL_ID
|
||||
-u CMUXD_UNIX_PATH
|
||||
-u CMUX_TAG
|
||||
-u CMUX_DEBUG_LOG
|
||||
-u CMUX_BUNDLE_ID
|
||||
-u CMUX_SHELL_INTEGRATION
|
||||
-u GHOSTTY_BIN_DIR
|
||||
|
|
@ -310,10 +318,11 @@ OPEN_CLEAN_ENV=(
|
|||
|
||||
if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
|
||||
# Ensure tag-specific socket paths win even if the caller has CMUX_* overrides.
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH"
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH"
|
||||
elif [[ -n "${TAG_SLUG:-}" ]]; then
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" open "$APP_PATH"
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH"
|
||||
else
|
||||
echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true
|
||||
"${OPEN_CLEAN_ENV[@]}" open "$APP_PATH"
|
||||
fi
|
||||
osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ PORTAL_PASS_THROUGH_EVENTS = DRAG_EVENTS + [
|
|||
"systemDefined",
|
||||
"applicationDefined",
|
||||
"periodic",
|
||||
"leftMouseDown",
|
||||
"leftMouseUp",
|
||||
"rightMouseDown",
|
||||
"rightMouseUp",
|
||||
"otherMouseDown",
|
||||
"otherMouseUp",
|
||||
"none",
|
||||
]
|
||||
|
||||
|
|
@ -117,9 +123,12 @@ def assert_hit_chain_routes_to_pane(
|
|||
raise cmuxError(
|
||||
f"drag_hit_chain({x},{y}) returned none ({reason})"
|
||||
)
|
||||
if "PaneContainerView" not in chain:
|
||||
# This probe is intended to catch root-level overlay capture regressions.
|
||||
# Depending on current AppKit event context, drag hit-testing can resolve
|
||||
# through either pane-local SwiftUI wrappers or portal-hosted terminal views.
|
||||
if "FileDropOverlayView" in chain:
|
||||
raise cmuxError(
|
||||
f"drag_hit_chain({x},{y}) missing PaneContainerView ({reason}); chain={chain}"
|
||||
f"drag_hit_chain({x},{y}) unexpectedly captured by FileDropOverlayView ({reason}); chain={chain}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -163,8 +172,7 @@ def main() -> int:
|
|||
assert_drop_gate(client, "local", expected=False, reason="tabtransfer drag must pass through")
|
||||
for event in PORTAL_PASS_THROUGH_EVENTS:
|
||||
assert_portal_gate(client, event, expected=True, reason="tabtransfer should pass through terminal portal")
|
||||
for event in ["leftMouseDown", "leftMouseUp", "rightMouseDown", "rightMouseUp", "otherMouseDown", "otherMouseUp", "scrollWheel"]:
|
||||
assert_portal_gate(client, event, expected=False, reason="non-drag events should not pass through portal")
|
||||
assert_portal_gate(client, "scrollWheel", expected=False, reason="scroll should not pass through portal")
|
||||
assert_sidebar_gate(client, "active", expected=False, reason="tabtransfer is not a sidebar drag payload")
|
||||
assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state")
|
||||
|
||||
|
|
@ -179,8 +187,7 @@ def main() -> int:
|
|||
assert_drop_gate(client, "local", expected=False, reason="sidebar reorder drag must pass through")
|
||||
for event in PORTAL_PASS_THROUGH_EVENTS:
|
||||
assert_portal_gate(client, event, expected=True, reason="sidebar reorder should pass through terminal portal")
|
||||
for event in ["leftMouseDown", "leftMouseUp", "rightMouseDown", "rightMouseUp", "otherMouseDown", "otherMouseUp", "scrollWheel"]:
|
||||
assert_portal_gate(client, event, expected=False, reason="non-drag events should not pass through portal")
|
||||
assert_portal_gate(client, "scrollWheel", expected=False, reason="scroll should not pass through portal")
|
||||
assert_sidebar_gate(client, "active", expected=True, reason="active sidebar drag should capture outside overlay")
|
||||
assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state")
|
||||
|
||||
|
|
@ -190,7 +197,7 @@ def main() -> int:
|
|||
for event in NON_DRAG_EVENTS + ["none"]:
|
||||
assert_gate(client, event, expected=False, reason="non-drag events should pass through")
|
||||
assert_drop_gate(client, "external", expected=True, reason="external file drags should be captured")
|
||||
assert_drop_gate(client, "local", expected=False, reason="local drags must not be captured")
|
||||
assert_drop_gate(client, "local", expected=True, reason="local file drags should be captured")
|
||||
for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]:
|
||||
assert_portal_gate(client, event, expected=False, reason="file drag should not trigger portal pass-through policy")
|
||||
assert_sidebar_gate(client, "active", expected=False, reason="file drag is not sidebar reorder payload")
|
||||
|
|
@ -203,8 +210,7 @@ def main() -> int:
|
|||
assert_drop_gate(client, "local", expected=False, reason="fileurl+tabtransfer must pass through")
|
||||
for event in PORTAL_PASS_THROUGH_EVENTS:
|
||||
assert_portal_gate(client, event, expected=True, reason="mixed fileurl+tabtransfer should still pass through portal")
|
||||
for event in ["leftMouseDown", "leftMouseUp", "rightMouseDown", "rightMouseUp", "otherMouseDown", "otherMouseUp", "scrollWheel"]:
|
||||
assert_portal_gate(client, event, expected=False, reason="non-drag events should not pass through portal")
|
||||
assert_portal_gate(client, "scrollWheel", expected=False, reason="scroll should not pass through portal")
|
||||
assert_sidebar_gate(client, "active", expected=False, reason="tabtransfer mix is not sidebar reorder payload")
|
||||
assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state")
|
||||
|
||||
|
|
@ -215,8 +221,7 @@ def main() -> int:
|
|||
assert_drop_gate(client, "local", expected=False, reason="fileurl+sidebarreorder must pass through")
|
||||
for event in PORTAL_PASS_THROUGH_EVENTS:
|
||||
assert_portal_gate(client, event, expected=True, reason="mixed fileurl+sidebarreorder should still pass through portal")
|
||||
for event in ["leftMouseDown", "leftMouseUp", "rightMouseDown", "rightMouseUp", "otherMouseDown", "otherMouseUp", "scrollWheel"]:
|
||||
assert_portal_gate(client, event, expected=False, reason="non-drag events should not pass through portal")
|
||||
assert_portal_gate(client, "scrollWheel", expected=False, reason="scroll should not pass through portal")
|
||||
assert_sidebar_gate(client, "active", expected=True, reason="sidebar reorder mix should keep sidebar outside overlay active")
|
||||
assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state")
|
||||
|
||||
|
|
|
|||
85
tests/test_terminal_drop_overlay_animation_probe.py
Normal file
85
tests/test_terminal_drop_overlay_animation_probe.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: terminal drop-target overlay should animate on initial show.
|
||||
|
||||
This exercises the focused terminal's drop-overlay code path via debug socket
|
||||
commands (no Accessibility/TCC/sudo required).
|
||||
"""
|
||||
|
||||
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")
|
||||
or os.environ.get("CMUX_SOCKET_PATH")
|
||||
or "/tmp/cmux-debug.sock"
|
||||
)
|
||||
|
||||
|
||||
def _parse_probe_response(response: str) -> dict[str, str]:
|
||||
if not response.startswith("OK "):
|
||||
raise cmuxError(response)
|
||||
parsed: dict[str, str] = {}
|
||||
for token in response.split()[1:]:
|
||||
if "=" not in token:
|
||||
continue
|
||||
key, value = token.split("=", 1)
|
||||
parsed[key] = value
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_bounds(bounds: str) -> tuple[float, float]:
|
||||
parts = bounds.split("x", 1)
|
||||
if len(parts) != 2:
|
||||
raise cmuxError(f"Unexpected bounds format: {bounds}")
|
||||
return float(parts[0]), float(parts[1])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
workspace_id = client.new_workspace()
|
||||
try:
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.25)
|
||||
|
||||
deferred_raw = client._send_command("terminal_drop_overlay_probe deferred")
|
||||
deferred = _parse_probe_response(deferred_raw)
|
||||
direct_raw = client._send_command("terminal_drop_overlay_probe direct")
|
||||
direct = _parse_probe_response(direct_raw)
|
||||
|
||||
width, height = _parse_bounds(deferred.get("bounds", "0x0"))
|
||||
if width <= 2 or height <= 2:
|
||||
raise cmuxError(
|
||||
f"Focused terminal bounds too small for overlay probe: {width}x{height}"
|
||||
)
|
||||
|
||||
if deferred.get("animated") != "1":
|
||||
raise cmuxError(
|
||||
"Deferred drop-overlay show did not animate. "
|
||||
f"response={deferred_raw}"
|
||||
)
|
||||
if direct.get("animated") != "1":
|
||||
raise cmuxError(
|
||||
"Direct drop-overlay show did not animate. "
|
||||
f"response={direct_raw}"
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
# Keep the test focused on overlay behavior; cleanup best-effort.
|
||||
pass
|
||||
|
||||
print("PASS: terminal drop overlay animates for deferred and direct show paths")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue