diff --git a/Sources/FileDropHintPresentation.swift b/Sources/FileDropHintPresentation.swift new file mode 100644 index 000000000000..25ba3f98158e --- /dev/null +++ b/Sources/FileDropHintPresentation.swift @@ -0,0 +1,118 @@ +import AppKit +import CmuxFoundation + +/// Owns only drag feedback. Dismissing a hint must not cancel WebKit's drop delivery. +@MainActor +final class FileDropHintPresentation: NSObject { + private enum Phase: Equatable { + case idle + case tracking(Int) + case dismissed(Int) + } + + let badge = FileDropHintBadgeView(frame: .zero) + private weak var hostWindow: NSWindow? + private var phase = Phase.idle + private var eventMonitor: Any? + private let displayDuration: Duration + private var deadline: MainActorCoalescingDeadlineTimer? + + init(displayDuration: Duration = .seconds(8)) { + self.displayDuration = displayDuration + super.init() + } + + deinit { + if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } + NotificationCenter.default.removeObserver(self) + } + + func setHostWindow(_ window: NSWindow?) { + guard hostWindow !== window else { return } + dismiss() + let center = NotificationCenter.default + center.removeObserver(self) + hostWindow = window + guard let window else { return } + + // AppKit delivers these lifecycle notifications synchronously on the main thread. + for name in [NSWindow.didResignKeyNotification, NSWindow.willCloseNotification] { + center.addObserver(self, selector: #selector(environmentDidChange(_:)), name: name, object: window) + } + for name in [NSWindow.didBecomeKeyNotification, NSApplication.didResignActiveNotification] { + center.addObserver(self, selector: #selector(environmentDidChange(_:)), name: name, object: nil) + } + } + + func begin(sequenceNumber: Int) { + if phase == .tracking(sequenceNumber) || phase == .dismissed(sequenceNumber) { + // AppKit may re-enter a destination after focus changes during the same drag. + // Its feedback stays dismissed until a genuinely new native session arrives. + return + } + dismiss() + phase = .tracking(sequenceNumber) + } + + func show(sequenceNumber: Int, text: String, centeredIn target: CGRect, clippedTo bounds: CGRect) { + guard case .tracking(let current) = phase, current == sequenceNumber, hostWindow != nil else { return } + badge.show(text: text, centeredIn: target, clippedTo: bounds) + if deadline == nil { + // A genuine display deadline driven by synchronous AppKit callbacks, not a retry. + deadline = MainActorCoalescingDeadlineTimer(owner: self) { $0.dismiss() } + } + if deadline?.isScheduled == false { + deadline?.schedule(after: displayDuration) + } + installEventMonitorIfNeeded() + } + + func hideBadge() { + badge.hideImmediately() + } + + func dismiss() { + if case .tracking(let sequenceNumber) = phase { + phase = .dismissed(sequenceNumber) + } + deadline?.cancel() + badge.hideImmediately() + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + self.eventMonitor = nil + } + } + + /// Preserve the native end/cancel event so AppKit can also remove the source drag image. + func handleEvent(_ event: NSEvent) -> NSEvent { + switch event.type { + case .leftMouseUp, .rightMouseUp, .otherMouseUp, + .leftMouseDown, .rightMouseDown, .otherMouseDown, .mouseMoved, .keyDown: + dismiss() + case .flagsChanged: + hideBadge() + default: + break + } + return event + } + + private func installEventMonitorIfNeeded() { + guard eventMonitor == nil else { return } + eventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseUp, .rightMouseUp, .otherMouseUp, + .leftMouseDown, .rightMouseDown, .otherMouseDown, + .mouseMoved, .keyDown, .flagsChanged] + ) { [weak self] event in + self?.handleEvent(event) ?? event + } + } + + @objc private func environmentDidChange(_ notification: Notification) { + if notification.name == NSWindow.didBecomeKeyNotification, + notification.object as? NSWindow === hostWindow { + return + } + dismiss() + } +} diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift index df7c4ca91694..9e769ce1511b 100644 --- a/Sources/FileDropOverlayView.swift +++ b/Sources/FileDropOverlayView.swift @@ -3,34 +3,6 @@ import Bonsplit import Foundation import WebKit -@MainActor -protocol FileDropPaneTarget: AnyObject { - func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation - func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation - func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) - func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool - func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool - func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) -} - -extension PaneDropTargetView: FileDropPaneTarget { - func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) } - func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) } - func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) } - func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) } - func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) } - func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) } -} - -extension BrowserPaneDropTargetView: FileDropPaneTarget { - func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) } - func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) } - func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) } - func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) } - func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) } - func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) } -} - /// Transparent NSView installed on the window's theme frame (above the NSHostingView) to /// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's /// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply @@ -61,7 +33,8 @@ final class FileDropOverlayView: NSView { var didPerformDragAsText = false weak var performedTextDragWebView: WKWebView? weak var performedTextPaneDropTarget: (any FileDropPaneTarget)? - let hintBadgeView = FileDropHintBadgeView(frame: .zero) + let hintPresentation = FileDropHintPresentation() + var hintBadgeView: FileDropHintBadgeView { hintPresentation.badge } var lastHitTestLogSignature: String? var lastDragRouteLogSignatureByPhase: [String: String] = [:] weak var hitTestReferenceView: NSView? @@ -275,6 +248,7 @@ final class FileDropOverlayView: NSView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + hintPresentation.setHostWindow(window) if window == nil { clearForwardedMouseDragState(reason: "overlayDetached") } @@ -299,6 +273,7 @@ final class FileDropOverlayView: NSView { // HTML5 drag events (dragenter, dragleave, drop) fire correctly. override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + hintPresentation.begin(sequenceNumber: sender.draggingSequenceNumber) return updateDragTarget(sender, phase: "entered") } @@ -307,7 +282,7 @@ final class FileDropOverlayView: NSView { } override func draggingExited(_ sender: (any NSDraggingInfo)?) { - hintBadgeView.hide() + hintPresentation.dismiss() preparedDragWebView = nil preparedPaneDropTarget = nil didPerformDragAsText = false @@ -316,6 +291,10 @@ final class FileDropOverlayView: NSView { exitActiveDragTargets(sender) } + override func draggingEnded(_ sender: any NSDraggingInfo) { + draggingExited(sender) + } + private func exitActiveDragTargets(_ sender: (any NSDraggingInfo)?) { if let prev = activeDragWebView { prev.draggingExited(sender) @@ -352,6 +331,7 @@ final class FileDropOverlayView: NSView { } override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + hintPresentation.dismiss() let hasLocalDraggingSource = sender.draggingSource != nil let types = sender.draggingPasteboard.types let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( @@ -414,6 +394,7 @@ final class FileDropOverlayView: NSView { } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + hintPresentation.dismiss() let hasLocalDraggingSource = sender.draggingSource != nil let types = sender.draggingPasteboard.types let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( @@ -421,7 +402,6 @@ final class FileDropOverlayView: NSView { hasLocalDraggingSource: hasLocalDraggingSource ) if shouldRouteFileDropToTextDestination(sender) { - hintBadgeView.hide() didPerformDragAsText = false performedTextDragWebView = nil performedTextPaneDropTarget = nil @@ -515,7 +495,7 @@ final class FileDropOverlayView: NSView { override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { defer { - hintBadgeView.hide() + hintPresentation.dismiss() preparedDragWebView = nil activeDragWebView = nil preparedPaneDropTarget = nil diff --git a/Sources/FileDropOverlayViewHitTesting.swift b/Sources/FileDropOverlayViewHitTesting.swift index 42739738be32..e14e8d41b7a3 100644 --- a/Sources/FileDropOverlayViewHitTesting.swift +++ b/Sources/FileDropOverlayViewHitTesting.swift @@ -131,10 +131,15 @@ extension FileDropOverlayView { !DragOverlayRoutingPolicy.currentModifierFlags.contains(.shift), let hintText = FileDropTextDestinationKind.editor.hintText(for: .preview), let targetBounds = hintBadgeTargetBoundsUnderPoint(windowPoint) else { - hintBadgeView.hide() + hintPresentation.hideBadge() return } - hintBadgeView.show(text: hintText, centeredIn: targetBounds, clippedTo: bounds) + hintPresentation.show( + sequenceNumber: sender.draggingSequenceNumber, + text: hintText, + centeredIn: targetBounds, + clippedTo: bounds + ) return } @@ -146,10 +151,15 @@ extension FileDropOverlayView { ), let kind, let hintText = kind.hintText(for: alternateBehavior), let targetBounds = hintBadgeTargetBoundsUnderPoint(windowPoint) else { - hintBadgeView.hide() + hintPresentation.hideBadge() return } - hintBadgeView.show(text: hintText, centeredIn: targetBounds, clippedTo: bounds) + hintPresentation.show( + sequenceNumber: sender.draggingSequenceNumber, + text: hintText, + centeredIn: targetBounds, + clippedTo: bounds + ) } func textDropDestinationKindUnderPoint(_ windowPoint: NSPoint) -> FileDropTextDestinationKind? { diff --git a/Sources/FileDropPaneTarget.swift b/Sources/FileDropPaneTarget.swift new file mode 100644 index 000000000000..64d7d01f0f16 --- /dev/null +++ b/Sources/FileDropPaneTarget.swift @@ -0,0 +1,29 @@ +import AppKit + +@MainActor +protocol FileDropPaneTarget: AnyObject { + func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation + func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation + func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) + func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool + func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool + func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) +} + +extension PaneDropTargetView: FileDropPaneTarget { + func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) } + func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) } + func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) } + func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) } + func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) } + func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) } +} + +extension BrowserPaneDropTargetView: FileDropPaneTarget { + func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) } + func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) } + func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) } + func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) } + func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) } + func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ec308662e736..d07dc154b4c0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1911,12 +1911,16 @@ 10200F1E0000000000000009 /* FileContentObservationLifetime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10200F1E000000000000000A /* FileContentObservationLifetime.swift */; }; 10200F1E0000000000000003 /* FileContentObserverTransferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10200F1E0000000000000004 /* FileContentObserverTransferTests.swift */; }; D0B1001AA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1001BA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift */; }; + D12925010000000000000001 /* FileDropHintPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12925010000000000000002 /* FileDropHintPresentation.swift */; }; + 713341E6979D0C6102E25549 /* FileDropOverlayDragCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A74B34B232D4D76037A67D5 /* FileDropOverlayDragCompletionTests.swift */; }; 781100017811000178110001 /* FileDropOverlayHitTestPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 781100027811000278110002 /* FileDropOverlayHitTestPerformanceTests.swift */; }; F0A1100D0000000000000001 /* FileDropOverlayMouseDragButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1100E0000000000000001 /* FileDropOverlayMouseDragButton.swift */; }; F0A1100F0000000000000001 /* FileDropOverlayMouseDragTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A110100000000000000001 /* FileDropOverlayMouseDragTarget.swift */; }; D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */; }; D0B10022A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */; }; + C82094EE9C3CFA5BE97C4833 /* FileDropOverlayViewLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB37A3E26005F130A2E0F04 /* FileDropOverlayViewLifecycleTests.swift */; }; D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */; }; + D12925020000000000000001 /* FileDropPaneTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12925020000000000000002 /* FileDropPaneTarget.swift */; }; F12748000000000000000001 /* FileEditorWordWrapShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12748000000000000000002 /* FileEditorWordWrapShortcutTests.swift */; }; FE5996010000000000000002 /* FileExplorerCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996010000000000000001 /* FileExplorerCellView.swift */; }; C40410010000000000000021 /* FileExplorerDoubleClickActionSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40410010000000000000020 /* FileExplorerDoubleClickActionSettings.swift */; }; @@ -6049,12 +6053,16 @@ 10200F1E000000000000000A /* FileContentObservationLifetime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FileContentObservationLifetime.swift; sourceTree = ""; }; 10200F1E0000000000000004 /* FileContentObserverTransferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileContentObserverTransferTests.swift; sourceTree = ""; }; D0B1001BA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropHintBadgeView.swift; sourceTree = ""; }; + D12925010000000000000002 /* FileDropHintPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropHintPresentation.swift; sourceTree = ""; }; + 4A74B34B232D4D76037A67D5 /* FileDropOverlayDragCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileDropOverlayDragCompletionTests.swift"; sourceTree = ""; }; 781100027811000278110002 /* FileDropOverlayHitTestPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayHitTestPerformanceTests.swift; sourceTree = ""; }; F0A1100E0000000000000001 /* FileDropOverlayMouseDragButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayMouseDragButton.swift; sourceTree = ""; }; F0A110100000000000000001 /* FileDropOverlayMouseDragTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayMouseDragTarget.swift; sourceTree = ""; }; D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayViewHitTesting.swift; sourceTree = ""; }; + 7EB37A3E26005F130A2E0F04 /* FileDropOverlayViewLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileDropOverlayViewLifecycleTests.swift"; sourceTree = ""; }; D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayViewTests.swift; sourceTree = ""; }; + D12925020000000000000002 /* FileDropPaneTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropPaneTarget.swift; sourceTree = ""; }; F12748000000000000000002 /* FileEditorWordWrapShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEditorWordWrapShortcutTests.swift; sourceTree = ""; }; FE5996010000000000000001 /* FileExplorerCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerCellView.swift; sourceTree = ""; }; C40410010000000000000020 /* FileExplorerDoubleClickActionSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerDoubleClickActionSettings.swift; sourceTree = ""; }; @@ -9352,6 +9360,8 @@ F0A110100000000000000001 /* FileDropOverlayMouseDragTarget.swift */, D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */, D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */, + D12925010000000000000002 /* FileDropHintPresentation.swift */, + D12925020000000000000002 /* FileDropPaneTarget.swift */, C0DE35010000000000000002 /* SidebarScrim.swift */, D0B10015A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift */, D0B10025A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift */, @@ -12004,6 +12014,8 @@ E30760060000000000000001 /* TmuxWorkspacePaneOverlayModelTests.swift */, BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, 781100027811000278110002 /* FileDropOverlayHitTestPerformanceTests.swift */, + 4A74B34B232D4D76037A67D5 /* FileDropOverlayDragCompletionTests.swift */, + 7EB37A3E26005F130A2E0F04 /* FileDropOverlayViewLifecycleTests.swift */, D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */, F0A110040000000000000001 /* GhosttyMouseSessionLedgerTests.swift */, 56813E59AFD4FF3217AD512A /* GhosttyTerminalMouseRoutingTests.swift */, @@ -14373,10 +14385,12 @@ 10200F1E0000000000000005 /* FileContentChangeObservingPanel.swift in Sources */, 10200F1E0000000000000009 /* FileContentObservationLifetime.swift in Sources */, D0B1001AA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift in Sources */, + D12925010000000000000001 /* FileDropHintPresentation.swift in Sources */, F0A1100D0000000000000001 /* FileDropOverlayMouseDragButton.swift in Sources */, F0A1100F0000000000000001 /* FileDropOverlayMouseDragTarget.swift in Sources */, D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */, D0B10022A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift in Sources */, + D12925020000000000000001 /* FileDropPaneTarget.swift in Sources */, FE5996010000000000000002 /* FileExplorerCellView.swift in Sources */, C40410010000000000000021 /* FileExplorerDoubleClickActionSettings.swift in Sources */, FE5996080000000000000002 /* FileExplorerExternalOpenMenuItems.swift in Sources */, @@ -16781,7 +16795,9 @@ FEEDC0DEC0DEC0DEC0DE0003 /* FeedJumpResolverTests.swift in Sources */, 330A15CE33CDB525DE207F7F /* FeedWaiterRegistryTests.swift in Sources */, 10200F1E0000000000000003 /* FileContentObserverTransferTests.swift in Sources */, + 713341E6979D0C6102E25549 /* FileDropOverlayDragCompletionTests.swift in Sources */, 781100017811000178110001 /* FileDropOverlayHitTestPerformanceTests.swift in Sources */, + C82094EE9C3CFA5BE97C4833 /* FileDropOverlayViewLifecycleTests.swift in Sources */, D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */, F12748000000000000000001 /* FileEditorWordWrapShortcutTests.swift in Sources */, FE002110 /* FileExplorerDoubleClickActionTests.swift in Sources */, diff --git a/cmuxTests/FileDropOverlayDragCompletionTests.swift b/cmuxTests/FileDropOverlayDragCompletionTests.swift new file mode 100644 index 000000000000..1c11e7b910af --- /dev/null +++ b/cmuxTests/FileDropOverlayDragCompletionTests.swift @@ -0,0 +1,120 @@ +import AppKit +import Testing +import WebKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct FileDropOverlayDragCompletionTests { + private final class DropWebView: WKWebView { + var prepareResult = true + var calls: [String] = [] + var onPerform: (() -> Void)? + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { .copy } + override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareResult } + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + calls.append("perform") + onPerform?() + return true + } + override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { calls.append("conclude") } + override func draggingExited(_ sender: (any NSDraggingInfo)?) { calls.append("exit") } + } + + private final class DragInfo: NSObject, NSDraggingInfo { + let draggingDestinationWindow: NSWindow? + let draggingSourceOperationMask: NSDragOperation = .copy + let draggingLocation: NSPoint + let draggedImageLocation: NSPoint = .zero + let draggedImage: NSImage? = nil + // NSDraggingInfo exposes these nonisolated; this test uses its immutable fixture only on MainActor. + nonisolated(unsafe) let draggingPasteboard: NSPasteboard + nonisolated(unsafe) let draggingSource: Any? = nil + let draggingSequenceNumber: Int = 12925 + var draggingFormation: NSDraggingFormation = .default + var animatesToDestination = false + var numberOfValidItemsForDrop = 1 + let springLoadingHighlight: NSSpringLoadingHighlight = .none + + init(window: NSWindow, pasteboard: NSPasteboard) { + draggingDestinationWindow = window + draggingLocation = NSPoint(x: 100, y: 100) + draggingPasteboard = pasteboard + } + func slideDraggedImage(to screenPoint: NSPoint) {} + override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? { nil } + func enumerateDraggingItems( + options enumOpts: NSDraggingItemEnumerationOptions = [], for view: NSView?, + classes classArray: [AnyClass], searchOptions: [NSPasteboard.ReadingOptionKey: Any] = [:], + using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void + ) {} + func resetSpringLoading() {} + } + + private func withBrowserDrag( + _ body: (NSWindow, FileDropOverlayView, DropWebView, DragInfo) throws -> Void + ) throws { + _ = NSApplication.shared + let bounds = NSRect(x: 0, y: 0, width: 420, height: 280) + let window = NSWindow(contentRect: bounds, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + let content = NSView(frame: bounds) + let webView = DropWebView(frame: bounds, configuration: WKWebViewConfiguration()) + content.addSubview(webView) + window.contentView = content + let overlay = FileDropOverlayView(frame: bounds) + overlay.hitTestReferenceView = content + try #require(content.superview).addSubview(overlay, positioned: .above, relativeTo: content) + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + #expect(pasteboard.writeObjects([URL(fileURLWithPath: "/tmp/issue-12925.txt") as NSURL])) + let drag = DragInfo(window: window, pasteboard: pasteboard) + #expect(overlay.draggingEntered(drag) == .copy) + #expect(!overlay.hintBadgeView.isHidden) + try body(window, overlay, webView, drag) + } + + @Test + func nativeDragEndClearsFeedbackEvenWithoutConclude() throws { + try withBrowserDrag { _, overlay, webView, drag in + overlay.draggingEnded(drag) + #expect(overlay.hintBadgeView.isHidden) + #expect(overlay.activeDragWebView == nil) + #expect(webView.calls == ["exit"]) + _ = overlay.draggingUpdated(drag) + #expect(overlay.hintBadgeView.isHidden) + } + } + + @Test + func rejectedPreparationDismissesHintWithoutWaitingForConclude() throws { + try withBrowserDrag { _, overlay, webView, drag in + webView.prepareResult = false + #expect(!overlay.prepareForDragOperation(drag)) + #expect(overlay.hintBadgeView.isHidden) + } + } + + @Test + func popupDuringDropDoesNotCancelBrowserDelivery() throws { + try withBrowserDrag { window, overlay, webView, drag in + webView.onPerform = { + NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) + #expect(overlay.hintBadgeView.isHidden) + } + #expect(overlay.prepareForDragOperation(drag)) + #expect(overlay.hintBadgeView.isHidden) + #expect(overlay.performDragOperation(drag)) + overlay.concludeDragOperation(drag) + #expect(webView.calls == ["perform", "conclude"]) + #expect(overlay.hintBadgeView.isHidden) + } + } +} diff --git a/cmuxTests/FileDropOverlayViewLifecycleTests.swift b/cmuxTests/FileDropOverlayViewLifecycleTests.swift new file mode 100644 index 000000000000..eb39ab3258f9 --- /dev/null +++ b/cmuxTests/FileDropOverlayViewLifecycleTests.swift @@ -0,0 +1,171 @@ +import AppKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct FileDropOverlayViewLifecycleTests { + private func makeOverlay() -> (NSWindow, FileDropOverlayView) { + _ = NSApplication.shared + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let overlay = FileDropOverlayView(frame: NSRect(x: 0, y: 0, width: 420, height: 280)) + window.contentView = overlay + return (window, overlay) + } + + private func showHint(in overlay: FileDropOverlayView, sequenceNumber: Int = 1) { + overlay.hintPresentation.begin(sequenceNumber: sequenceNumber) + overlay.hintPresentation.show( + sequenceNumber: sequenceNumber, + text: "Hold Shift to open as split", + centeredIn: overlay.bounds, + clippedTo: overlay.bounds + ) + #expect(!overlay.hintBadgeView.isHidden) + } + + private func close(_ window: NSWindow) { + window.close() + } + + @Test + func draggingExitHidesHint() { + let (window, overlay) = makeOverlay() + defer { close(window) } + showHint(in: overlay) + overlay.draggingExited(nil) + #expect(overlay.hintBadgeView.isHidden) + #expect(overlay.hintBadgeView.accessibilityLabel() == nil) + } + + @Test + func windowResignationHidesHintImmediately() { + let (window, overlay) = makeOverlay() + defer { close(window) } + showHint(in: overlay) + NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) + #expect(overlay.hintBadgeView.isHidden) + } + + @Test + func anotherKeyWindowHidesHintImmediately() { + let (window, overlay) = makeOverlay() + defer { close(window) } + let popup = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 120), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + popup.isReleasedWhenClosed = false + defer { close(popup) } + showHint(in: overlay) + NotificationCenter.default.post(name: NSWindow.didBecomeKeyNotification, object: popup) + #expect(overlay.hintBadgeView.isHidden) + } + + @Test + func applicationDeactivationHidesHintImmediately() { + let (window, overlay) = makeOverlay() + defer { close(window) } + showHint(in: overlay) + NotificationCenter.default.post(name: NSApplication.didResignActiveNotification, object: NSApp) + #expect(overlay.hintBadgeView.isHidden) + } + + @Test + func closingOrDetachingWindowHidesHint() { + let (window, overlay) = makeOverlay() + defer { close(window) } + showHint(in: overlay) + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + #expect(overlay.hintBadgeView.isHidden) + showHint(in: overlay, sequenceNumber: 2) + window.contentView = NSView() + #expect(overlay.hintBadgeView.isHidden) + } + + @Test + func ownWindowBecomingKeyPreservesLiveHint() { + let (window, overlay) = makeOverlay() + defer { close(window) } + showHint(in: overlay) + NotificationCenter.default.post(name: NSWindow.didBecomeKeyNotification, object: window) + #expect(!overlay.hintBadgeView.isHidden) + } + + @Test + func dismissedDragCannotReshowHintButNextDragCan() { + let (window, overlay) = makeOverlay() + defer { close(window) } + showHint(in: overlay) + NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) + overlay.hintPresentation.begin(sequenceNumber: 1) + overlay.hintPresentation.show( + sequenceNumber: 1, text: "stale", centeredIn: overlay.bounds, clippedTo: overlay.bounds + ) + #expect(overlay.hintBadgeView.isHidden) + showHint(in: overlay, sequenceNumber: 2) + overlay.hintPresentation.show( + sequenceNumber: 1, text: "stale", centeredIn: overlay.bounds, clippedTo: overlay.bounds + ) + #expect(overlay.hintBadgeView.accessibilityLabel() == "Hold Shift to open as split") + } + + @Test(arguments: [NSEvent.EventType.leftMouseUp, .rightMouseUp, .otherMouseUp, .mouseMoved]) + func releaseDismissesHintWithoutConsumingNativeEvent(_ type: NSEvent.EventType) throws { + let (window, overlay) = makeOverlay() + defer { close(window) } + showHint(in: overlay) + let event = try #require(NSEvent.mouseEvent( + with: type, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 0 + )) + #expect(overlay.hintPresentation.handleEvent(event) === event) + #expect(overlay.hintBadgeView.isHidden) + } + + @Test + func escapeDismissesHintWithoutConsumingNativeCancellation() throws { + let (window, overlay) = makeOverlay() + defer { close(window) } + showHint(in: overlay) + let escape = try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53 + )) + #expect(overlay.hintPresentation.handleEvent(escape) === escape) + #expect(overlay.hintBadgeView.isHidden) + } + + @Test + func displayDeadlineDismissesEvenIfAppKitKeepsUpdatingDrag() async { + let (window, _) = makeOverlay() + defer { close(window) } + let presentation = FileDropHintPresentation(displayDuration: .zero) + presentation.setHostWindow(window) + presentation.begin(sequenceNumber: 10) + let bounds = CGRect(x: 0, y: 0, width: 420, height: 280) + presentation.show(sequenceNumber: 10, text: "hint", centeredIn: bounds, clippedTo: bounds) + // Release MainActor so the real zero-duration timer can run on the main queue. + let limit = Date().addingTimeInterval(1) + while !presentation.badge.isHidden, Date() < limit { + await Task.yield() + } + #expect(presentation.badge.isHidden) + presentation.show(sequenceNumber: 10, text: "stale", centeredIn: bounds, clippedTo: bounds) + #expect(presentation.badge.isHidden) + } +}