diff --git a/CLAUDE.md b/CLAUDE.md index 660c4c80..beb24aa0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `): `/tmp/cmux-debug-.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` diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5db2015a..38ec443e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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. diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 49438daf..5671c701 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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 diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ece08a2b..0e30968a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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 - 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 - 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": diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 29348c39..e8c4953d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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 { diff --git a/scripts/reload.sh b/scripts/reload.sh index b6d6f8d5..3cd2bb63 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -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 diff --git a/tests/test_bonsplit_tab_drag_overlay_gate.py b/tests/test_bonsplit_tab_drag_overlay_gate.py index 1d988e04..1af98c3d 100644 --- a/tests/test_bonsplit_tab_drag_overlay_gate.py +++ b/tests/test_bonsplit_tab_drag_overlay_gate.py @@ -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") diff --git a/tests/test_terminal_drop_overlay_animation_probe.py b/tests/test_terminal_drop_overlay_animation_probe.py new file mode 100644 index 00000000..0a08a271 --- /dev/null +++ b/tests/test_terminal_drop_overlay_animation_probe.py @@ -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())