Harden drag overlay routing and add terminal overlay regression probes

This commit is contained in:
Lawrence Chen 2026-02-20 19:58:58 -08:00
parent 9388358914
commit a5c7600458
8 changed files with 329 additions and 20 deletions

View file

@ -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`

View file

@ -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.

View file

@ -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

View file

@ -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":

View file

@ -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 {

View file

@ -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

View file

@ -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")

View 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())