diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index f591f314316c..b75a17957153 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -60,6 +60,9 @@ D0B1000CA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */; }; D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000FA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift */; }; D0B10010A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10011A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift */; }; + D0B10014A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10015A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift */; }; + D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */; }; + D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */; }; D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000002 /* SidebarDropPlanner.swift */; }; D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */; }; @@ -447,6 +450,7 @@ D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalTabDragRoutingTests.swift; sourceTree = ""; }; D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewDragRoutingTests.swift; sourceTree = ""; }; D0B1000FA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneDropRoutingTests.swift; sourceTree = ""; }; + D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayViewTests.swift; sourceTree = ""; }; 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePullRequestSidebarTests.swift; sourceTree = ""; }; 1A1B2C3D4E5F607180000002 /* WorkspacePromptSubmit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePromptSubmit.swift; sourceTree = ""; }; @@ -565,6 +569,8 @@ D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewSupport.swift; sourceTree = ""; }; D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+SurfaceConfigurationReload.swift"; sourceTree = ""; }; D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = ""; }; + D0B10015A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragOverlayRoutingPolicy.swift; sourceTree = ""; }; + D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewTextEditor.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; @@ -972,6 +978,7 @@ B37A00000000000000000008 /* SettingsCardNote.swift */, A5001012 /* ContentView.swift */, C0DE35010000000000000002 /* SidebarScrim.swift */, + D0B10015A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift */, C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */, C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */, D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */, @@ -1091,6 +1098,7 @@ A5001418 /* MarkdownPanel.swift */, A5001419 /* MarkdownPanelView.swift */, A5001423 /* FilePreviewPanel.swift */, + D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */, A5001442A5001442A5001442 /* FilePreviewModeSupport.swift */, A5001444A5001444A5001444 /* FilePreviewWorkspaceOpenSupport.swift */, A5001446A5001446A5001446 /* FilePreviewMagnifyingPDFView.swift */, @@ -1245,6 +1253,7 @@ 1A1B2C3D4E5F607180000004 /* WorkspacePromptSubmitTests.swift */, C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */, BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, + D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */, 2907A0042907A0042907A004 /* AppDelegateIssue2907RoutingTests.swift */, A5001435A5001435A5001435 /* FilePreviewPDFThumbnailSidebarTests.swift */, C0DE31390000000000000104 /* FilePreviewReviewFeedbackTests.swift */, @@ -1613,6 +1622,7 @@ D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */, D0B10012A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift in Sources */, D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */, + D0B10014A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, D0B10006A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift in Sources */, A5001534 /* BrowserWindowPortal.swift in Sources */, @@ -1665,6 +1675,7 @@ A5001420 /* MarkdownPanel.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, A5001422 /* FilePreviewPanel.swift in Sources */, + D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */, A5001443A5001443A5001443 /* FilePreviewModeSupport.swift in Sources */, A5001445A5001445A5001445 /* FilePreviewWorkspaceOpenSupport.swift in Sources */, A5001447A5001447A5001447 /* FilePreviewMagnifyingPDFView.swift in Sources */, @@ -1857,6 +1868,7 @@ 1A1B2C3D4E5F607180000003 /* WorkspacePromptSubmitTests.swift in Sources */, C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */, 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, + D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */, 2907A0032907A0032907A003 /* AppDelegateIssue2907RoutingTests.swift in Sources */, A5001436A5001436A5001436 /* FilePreviewPDFThumbnailSidebarTests.swift in Sources */, C0DE31390000000000000103 /* FilePreviewReviewFeedbackTests.swift in Sources */, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5edc05afcf97..f698c533aec4 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7,125 +7,6 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit -// MARK: - File Drop Overlay - -enum DragOverlayRoutingPolicy { - static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") - static let filePreviewTransferType = NSPasteboard.PasteboardType("com.cmux.filepreview.transfer") - static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - - static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(bonsplitTabTransferType) - } - - static func hasFilePreviewTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(filePreviewTransferType) - } - - static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(sidebarTabReorderType) - } - - static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - PasteboardFileURLReader.hasFileURLType(pasteboardTypes ?? []) - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - hasLocalDraggingSource: Bool - ) -> Bool { - // External Finder file drops need the stable root AppKit destination so - // terminal/browser panes receive their shared file insertion/upload path. - // Internal cmux drag payloads keep their dedicated pane routing. - guard hasFileURL(pasteboardTypes) else { return false } - guard !hasFilePreviewTransfer(pasteboardTypes), - !hasBonsplitTabTransfer(pasteboardTypes), - !hasSidebarTabReorder(pasteboardTypes) else { - return false - } - guard !hasLocalDraggingSource else { return false } - return true - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureFileDropDestination( - pasteboardTypes: pasteboardTypes, - hasLocalDraggingSource: false - ) - } - - static func shouldCaptureFileDropOverlay( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } - guard isDragMouseEvent(eventType) else { return false } - return true - } - - static func shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: Bool, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - guard hasSidebarDragState else { return false } - return hasSidebarTabReorder(pasteboardTypes) - } - - static func shouldCaptureSidebarExternalOverlay( - draggedTabId: UUID?, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: draggedTabId != nil, - pasteboardTypes: pasteboardTypes - ) - } - - static func shouldPassThroughPortalHitTesting( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard isPortalDragEvent(eventType) else { return false } - return hasBonsplitTabTransfer(pasteboardTypes) - || hasFilePreviewTransfer(pasteboardTypes) - || hasSidebarTabReorder(pasteboardTypes) - } - - static func shouldPassThroughTerminalPortalHitTesting( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard isPortalDragEvent(eventType) else { return false } - return shouldPassThroughPortalHitTesting( - pasteboardTypes: pasteboardTypes, - eventType: eventType - ) || hasFileURL(pasteboardTypes) - } - - private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { - eventType == .leftMouseDragged - || eventType == .rightMouseDragged - || eventType == .otherMouseDragged - } - - private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { - // Restrict portal pass-through to explicit drag-motion events so stale - // NSPasteboard(name: .drag) types cannot hijack normal pointer input. - guard let eventType else { return false } - switch eventType { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - return true - default: - return false - } - } -} - /// 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 @@ -146,6 +27,10 @@ final class FileDropOverlayView: NSView { /// The WKWebView that accepted prepareForDragOperation so conclude can be /// delivered to the same browser target after the drop completes. private weak var preparedDragWebView: WKWebView? + /// Pane drop target currently receiving delegated file drag events. + private weak var activePaneDropTarget: PaneDropTargetView? + /// Pane drop target that accepted prepareForDragOperation. + private weak var preparedPaneDropTarget: PaneDropTargetView? private var lastHitTestLogSignature: String? private var lastDragRouteLogSignatureByPhase: [String: String] = [:] @@ -213,6 +98,9 @@ final class FileDropOverlayView: NSView { ) #endif guard shouldCapture else { return nil } + if shouldDeferFileDropOverlayToBonsplitTabBar(at: point) { + return nil + } return super.hitTest(point) } @@ -312,10 +200,15 @@ final class FileDropOverlayView: NSView { override func draggingExited(_ sender: (any NSDraggingInfo)?) { preparedDragWebView = nil + preparedPaneDropTarget = nil if let prev = activeDragWebView { prev.draggingExited(sender) activeDragWebView = nil } + if let prev = activePaneDropTarget { + prev.draggingExited(sender) + activePaneDropTarget = nil + } } override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { @@ -326,19 +219,23 @@ final class FileDropOverlayView: NSView { hasLocalDraggingSource: hasLocalDraggingSource ) let webView = shouldCapture ? (activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil + let paneDropTarget = shouldCapture && webView == nil + ? (activePaneDropTarget ?? paneDropTargetUnderPoint(sender.draggingLocation)) + : nil + let hasPaneTarget = paneDropTarget != nil || terminalUnderPoint(sender.draggingLocation) != nil #if DEBUG logDragRouteDecision( phase: "prepare", pasteboardTypes: types, shouldCapture: shouldCapture, hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget + hasPaneTarget: hasPaneTarget ) #endif guard shouldCapture else { preparedDragWebView = nil + preparedPaneDropTarget = nil + activePaneDropTarget = nil return false } if let webView { @@ -346,7 +243,13 @@ final class FileDropOverlayView: NSView { return webView.prepareForDragOperation(sender) } preparedDragWebView = nil - return hasTerminalTarget + if let paneDropTarget { + let accepted = paneDropTarget.prepareForDragOperation(sender) + preparedPaneDropTarget = accepted ? paneDropTarget : nil + return accepted + } + preparedPaneDropTarget = nil + return hasPaneTarget } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { @@ -359,20 +262,25 @@ final class FileDropOverlayView: NSView { let webView = shouldCapture ? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil + let paneDropTarget = shouldCapture && webView == nil + ? (preparedPaneDropTarget ?? activePaneDropTarget ?? paneDropTargetUnderPoint(sender.draggingLocation)) + : nil + let terminal = paneDropTarget == nil ? terminalUnderPoint(sender.draggingLocation) : nil + let hasPaneTarget = paneDropTarget != nil || terminal != nil #if DEBUG logDragRouteDecision( phase: "perform", pasteboardTypes: types, shouldCapture: shouldCapture, hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget + hasPaneTarget: hasPaneTarget ) #endif guard shouldCapture else { preparedDragWebView = nil activeDragWebView = nil + preparedPaneDropTarget = nil + activePaneDropTarget = nil return false } if let webView { @@ -380,6 +288,15 @@ final class FileDropOverlayView: NSView { return webView.performDragOperation(sender) } preparedDragWebView = nil + if let paneDropTarget { + let handled = paneDropTarget.performDragOperation(sender) + if !handled { + preparedPaneDropTarget = nil + activePaneDropTarget = nil + } + return handled + } + preparedPaneDropTarget = nil activeDragWebView = nil guard let terminal else { return false } return terminal.performDragOperation(sender) @@ -389,6 +306,8 @@ final class FileDropOverlayView: NSView { defer { preparedDragWebView = nil activeDragWebView = nil + preparedPaneDropTarget = nil + activePaneDropTarget = nil } guard let sender else { return } guard DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( @@ -399,6 +318,9 @@ final class FileDropOverlayView: NSView { } let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) webView?.concludeDragOperation(sender) + if let paneDropTarget = preparedPaneDropTarget ?? activePaneDropTarget { + paneDropTarget.concludeDragOperation(sender) + } } private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { @@ -410,11 +332,16 @@ final class FileDropOverlayView: NSView { hasLocalDraggingSource: hasLocalDraggingSource ) let webView = shouldCapture ? webViewUnderPoint(loc) : nil + let paneDropTarget = shouldCapture && webView == nil ? paneDropTargetUnderPoint(loc) : nil if let prev = activeDragWebView, prev !== webView { prev.draggingExited(sender) activeDragWebView = nil } + if let prev = activePaneDropTarget, prev !== paneDropTarget { + prev.draggingExited(sender) + activePaneDropTarget = nil + } if let webView { if activeDragWebView !== webView { @@ -424,17 +351,25 @@ final class FileDropOverlayView: NSView { return webView.draggingUpdated(sender) } - let hasTerminalTarget = terminalUnderPoint(loc) != nil + if let paneDropTarget { + if activePaneDropTarget !== paneDropTarget { + activePaneDropTarget = paneDropTarget + return paneDropTarget.draggingEntered(sender) + } + return paneDropTarget.draggingUpdated(sender) + } + + let hasPaneTarget = terminalUnderPoint(loc) != nil #if DEBUG logDragRouteDecision( phase: phase, pasteboardTypes: types, shouldCapture: shouldCapture, hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget + hasPaneTarget: hasPaneTarget ) #endif - guard shouldCapture, hasTerminalTarget else { return [] } + guard shouldCapture, hasPaneTarget else { return [] } return .copy } @@ -573,13 +508,13 @@ final class FileDropOverlayView: NSView { pasteboardTypes: [NSPasteboard.PasteboardType]?, shouldCapture: Bool, hasLocalDraggingSource: Bool, - hasTerminalTarget: Bool + hasPaneTarget: Bool ) { guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } let signature = [ shouldCapture ? "1" : "0", hasLocalDraggingSource ? "1" : "0", - hasTerminalTarget ? "1" : "0", + hasPaneTarget ? "1" : "0", debugPasteboardTypes(pasteboardTypes) ].joined(separator: "|") guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } @@ -587,7 +522,7 @@ final class FileDropOverlayView: NSView { cmuxDebugLog( "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + - "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + + "hasPane=\(hasPaneTarget ? 1 : 0) " + "types=\(debugPasteboardTypes(pasteboardTypes))" ) } @@ -612,6 +547,44 @@ final class FileDropOverlayView: NSView { } return nil } + + private func shouldDeferFileDropOverlayToBonsplitTabBar(at point: NSPoint) -> Bool { + guard let window else { return false } + let windowPoint = convert(point, to: nil) + return BonsplitTabBarHitRegionRegistry.containsWindowPoint(windowPoint, in: window) + } + + private func paneDropTargetUnderPoint(_ windowPoint: NSPoint) -> PaneDropTargetView? { + if let paneTarget = inlinePaneDropTargetUnderPoint(windowPoint) { + return paneTarget + } + guard let window else { return nil } + return TerminalWindowPortalRegistry.terminalPaneDropTargetAtWindowPoint(windowPoint, in: window) + } + + private func inlinePaneDropTargetUnderPoint(_ windowPoint: NSPoint) -> PaneDropTargetView? { + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + + let point = contentView.convert(windowPoint, from: nil) + return paneDropTarget(in: contentView, at: point) + } + + private func paneDropTarget(in view: NSView, at point: NSPoint) -> PaneDropTargetView? { + for subview in view.subviews.reversed() { + guard !subview.isHidden, subview.alphaValue > 0 else { continue } + let pointInSubview = subview.convert(point, from: view) + guard subview.bounds.contains(pointInSubview) else { continue } + if let paneTarget = subview as? PaneDropTargetView { + return paneTarget + } + if let nestedTarget = paneDropTarget(in: subview, at: pointInSubview) { + return nestedTarget + } + } + return view as? PaneDropTargetView + } } var fileDropOverlayKey: UInt8 = 0 @@ -14826,7 +14799,7 @@ private final class SidebarDragAutoScrollController: ObservableObject { } } -private enum SidebarTabDragPayload { +enum SidebarTabDragPayload { static let typeIdentifier = "com.cmux.sidebar-tab-reorder" static let dropContentType = UTType(exportedAs: typeIdentifier) static let dropContentTypes: [UTType] = [dropContentType] diff --git a/Sources/DragOverlayRoutingPolicy.swift b/Sources/DragOverlayRoutingPolicy.swift new file mode 100644 index 000000000000..0847ddf435b5 --- /dev/null +++ b/Sources/DragOverlayRoutingPolicy.swift @@ -0,0 +1,114 @@ +import AppKit +import Foundation + +enum DragOverlayRoutingPolicy { + static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + static let filePreviewTransferType = NSPasteboard.PasteboardType("com.cmux.filepreview.transfer") + static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) + + static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(bonsplitTabTransferType) + } + + static func hasFilePreviewTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(filePreviewTransferType) + } + + static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(sidebarTabReorderType) + } + + static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + PasteboardFileURLReader.hasFileURLType(pasteboardTypes ?? []) + } + + static func fileURLs(from pasteboard: NSPasteboard) -> [URL] { + PasteboardFileURLReader.fileURLs(from: pasteboard) + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + hasLocalDraggingSource: Bool + ) -> Bool { + // The window overlay delegates Finder/sidebar files to pane-level Bonsplit targets. + _ = hasLocalDraggingSource + guard hasFileURL(pasteboardTypes) else { return false } + return true + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureFileDropDestination( + pasteboardTypes: pasteboardTypes, + hasLocalDraggingSource: false + ) + } + + static func shouldCaptureFileDropOverlay( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } + guard isDragMouseEvent(eventType) else { return false } + return true + } + + static func shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: Bool, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + guard hasSidebarDragState else { return false } + return hasSidebarTabReorder(pasteboardTypes) + } + + static func shouldCaptureSidebarExternalOverlay( + draggedTabId: UUID?, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: draggedTabId != nil, + pasteboardTypes: pasteboardTypes + ) + } + + static func shouldPassThroughPortalHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard isPortalDragEvent(eventType) else { return false } + return hasBonsplitTabTransfer(pasteboardTypes) + || hasFilePreviewTransfer(pasteboardTypes) + || hasSidebarTabReorder(pasteboardTypes) + } + + static func shouldPassThroughTerminalPortalHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard isPortalDragEvent(eventType) else { return false } + return shouldPassThroughPortalHitTesting( + pasteboardTypes: pasteboardTypes, + eventType: eventType + ) || hasFileURL(pasteboardTypes) + } + + private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + } + + private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { + guard let eventType else { return false } + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 38ad43fc6559..79c26ba4200c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10926,6 +10926,14 @@ final class GhosttySurfaceScrollView: NSView { } } + func paneDropTargetForDrop(at localPoint: NSPoint) -> TerminalPaneDropTargetView? { + guard bounds.contains(localPoint) else { return nil } + let pointInTarget = paneDropTargetView.convert(localPoint, from: self) + guard paneDropTargetView.bounds.contains(pointInTarget) else { return nil } + guard !paneDropTargetView.shouldDeferToPaneTabBar(at: pointInTarget) else { return nil } + return paneDropTargetView + } + #if DEBUG private func logDropZoneOverlay(event: String, zone: DropZone?, frame: CGRect?) { let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 0e3743014544..c9308c964609 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -183,7 +183,9 @@ final class FilePreviewDragPasteboardWriter: NSObject, NSPasteboardWriting { } func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { - [ + let data = transferDataForDrag() + mirrorTransferDataToDragPasteboard(data) + return [ DragOverlayRoutingPolicy.filePreviewTransferType, Self.bonsplitTransferType, .fileURL @@ -772,11 +774,20 @@ struct FilePreviewPanelView: View { let isFocused: Bool let isVisibleInUI: Bool let portalPriority: Int + let appearance: PanelAppearance let onRequestPanelFocus: () -> Void @State private var focusFlashOpacity = 0.0 @State private var focusFlashAnimationGeneration = 0 + private var themeBackgroundColor: NSColor { + appearance.backgroundColor + } + + private var themeForegroundColor: NSColor { + appearance.foregroundColor + } + var body: some View { VStack(spacing: 0) { if panel.previewMode != .pdf { @@ -786,7 +797,7 @@ struct FilePreviewPanelView: View { content } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) + .background(Color(nsColor: themeBackgroundColor)) .overlay { RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) @@ -811,7 +822,7 @@ struct FilePreviewPanelView: View { .frame(width: 16) Text(panel.filePath) .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.secondary) + .foregroundStyle(Color(nsColor: themeForegroundColor).opacity(0.68)) .lineLimit(1) .truncationMode(.middle) .textSelection(.enabled) @@ -840,7 +851,7 @@ struct FilePreviewPanelView: View { } .padding(.horizontal, 12) .frame(height: 30) - .background(Color(nsColor: .windowBackgroundColor)) + .background(Color(nsColor: themeBackgroundColor)) } @ViewBuilder @@ -850,7 +861,12 @@ struct FilePreviewPanelView: View { } else { switch panel.previewMode { case .text: - FilePreviewTextEditor(panel: panel, isVisibleInUI: isVisibleInUI) + FilePreviewTextEditor( + panel: panel, + isVisibleInUI: isVisibleInUI, + themeBackgroundColor: themeBackgroundColor, + themeForegroundColor: themeForegroundColor + ) case .pdf: FilePreviewPDFView(panel: panel, isVisibleInUI: isVisibleInUI) case .image: @@ -909,188 +925,6 @@ struct FilePreviewPanelView: View { } } -private struct FilePreviewTextEditor: NSViewRepresentable { - @ObservedObject var panel: FilePreviewPanel - let isVisibleInUI: Bool - - func makeCoordinator() -> Coordinator { - Coordinator(panel: panel) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() - scrollView.isHidden = !isVisibleInUI - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = true - scrollView.autohidesScrollers = true - scrollView.borderType = .noBorder - scrollView.drawsBackground = false - - let textView = SavingTextView() - textView.panel = panel - textView.delegate = context.coordinator - textView.isEditable = true - textView.isSelectable = true - textView.allowsUndo = true - textView.isRichText = false - textView.importsGraphics = false - textView.usesFindPanel = true - textView.usesFontPanel = false - textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular) - textView.textColor = .labelColor - textView.backgroundColor = .textBackgroundColor - textView.insertionPointColor = .labelColor - textView.minSize = NSSize(width: 0, height: 0) - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = true - textView.autoresizingMask = [.width] - textView.textContainer?.containerSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - textView.textContainer?.widthTracksTextView = false - textView.applyFilePreviewTextEditorInsets() - textView.string = panel.textContent - panel.attachTextView(textView) - - scrollView.documentView = textView - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - context.coordinator.panel = panel - scrollView.isHidden = !isVisibleInUI - guard let textView = scrollView.documentView as? SavingTextView else { return } - textView.panel = panel - textView.applyFilePreviewTextEditorInsets() - panel.attachTextView(textView) - guard textView.string != panel.textContent else { return } - context.coordinator.isApplyingPanelUpdate = true - textView.string = panel.textContent - context.coordinator.isApplyingPanelUpdate = false - } - - final class Coordinator: NSObject, NSTextViewDelegate { - var panel: FilePreviewPanel - var isApplyingPanelUpdate = false - - init(panel: FilePreviewPanel) { - self.panel = panel - } - - func textDidChange(_ notification: Notification) { - guard !isApplyingPanelUpdate, - let textView = notification.object as? NSTextView else { return } - panel.updateTextContent(textView.string) - } - } -} - -enum FilePreviewTextEditorLayout { - static let textContainerInset = NSSize(width: 12, height: 10) - static let lineFragmentPadding: CGFloat = 0 -} - -extension NSTextView { - func applyFilePreviewTextEditorInsets() { - let targetInset = FilePreviewTextEditorLayout.textContainerInset - if textContainerInset.width != targetInset.width || textContainerInset.height != targetInset.height { - textContainerInset = targetInset - } - if textContainer?.lineFragmentPadding != FilePreviewTextEditorLayout.lineFragmentPadding { - textContainer?.lineFragmentPadding = FilePreviewTextEditorLayout.lineFragmentPadding - } - } -} - -final class SavingTextView: NSTextView { - private static let defaultPreviewFontSize: CGFloat = 13 - private static let minimumPreviewFontSize: CGFloat = 8 - private static let maximumPreviewFontSize: CGFloat = 36 - - weak var panel: FilePreviewPanel? - private var previewFontSize: CGFloat = 13 - private var pendingSaveShortcutChordPrefix: ShortcutStroke? - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - applyFilePreviewTextEditorInsets() - panel?.retryPendingFocus() - } - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - guard event.type == .keyDown else { - return super.performKeyEquivalent(with: event) - } - guard let shouldSave = saveShortcutMatch(for: event) else { - return super.performKeyEquivalent(with: event) - } - if shouldSave { - panel?.saveTextContent() - } - return true - } - - override func magnify(with event: NSEvent) { - let factor = 1.0 + event.magnification - guard factor.isFinite, factor > 0 else { return } - adjustPreviewFontSize(by: factor) - } - - override func scrollWheel(with event: NSEvent) { - guard FilePreviewInteraction.hasZoomModifier(event) else { - super.scrollWheel(with: event) - return - } - adjustPreviewFontSize(by: FilePreviewInteraction.zoomFactor(forScroll: event)) - } - - override func smartMagnify(with event: NSEvent) { - if previewFontSize == Self.defaultPreviewFontSize { - setPreviewFontSize(18) - } else { - setPreviewFontSize(Self.defaultPreviewFontSize) - } - } - - private func adjustPreviewFontSize(by factor: CGFloat) { - setPreviewFontSize(previewFontSize * factor) - } - - private func setPreviewFontSize(_ nextFontSize: CGFloat) { - let clamped = min(max(nextFontSize, Self.minimumPreviewFontSize), Self.maximumPreviewFontSize) - guard clamped.isFinite else { return } - previewFontSize = clamped - let nextFont = NSFont.monospacedSystemFont(ofSize: clamped, weight: .regular) - font = nextFont - typingAttributes[.font] = nextFont - } - - private func saveShortcutMatch(for event: NSEvent) -> Bool? { - let shortcut = KeyboardShortcutSettings.shortcut(for: .saveFilePreview) - guard shortcut.hasChord else { - pendingSaveShortcutChordPrefix = nil - return shortcut.matches(event: event) ? true : nil - } - - if let pendingPrefix = pendingSaveShortcutChordPrefix { - pendingSaveShortcutChordPrefix = nil - guard pendingPrefix == shortcut.firstStroke, - let secondStroke = shortcut.secondStroke else { - return nil - } - return secondStroke.matches(event: event) ? true : nil - } - - if shortcut.firstStroke.matches(event: event) { - pendingSaveShortcutChordPrefix = shortcut.firstStroke - return false - } - return nil - } -} - private struct FilePreviewPDFView: NSViewRepresentable { let panel: FilePreviewPanel let isVisibleInUI: Bool diff --git a/Sources/Panels/FilePreviewTextEditor.swift b/Sources/Panels/FilePreviewTextEditor.swift new file mode 100644 index 000000000000..ef198b413a93 --- /dev/null +++ b/Sources/Panels/FilePreviewTextEditor.swift @@ -0,0 +1,204 @@ +import AppKit +import SwiftUI + +struct FilePreviewTextEditor: NSViewRepresentable { + @ObservedObject var panel: FilePreviewPanel + let isVisibleInUI: Bool + let themeBackgroundColor: NSColor + let themeForegroundColor: NSColor + + func makeCoordinator() -> Coordinator { + Coordinator(panel: panel) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.isHidden = !isVisibleInUI + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.drawsBackground = true + + let textView = SavingTextView() + textView.panel = panel + textView.delegate = context.coordinator + textView.isEditable = true + textView.isSelectable = true + textView.allowsUndo = true + textView.isRichText = false + textView.importsGraphics = false + textView.usesFindPanel = true + textView.usesFontPanel = false + textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + textView.drawsBackground = true + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = true + textView.autoresizingMask = [.width] + textView.textContainer?.containerSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.textContainer?.widthTracksTextView = false + textView.applyFilePreviewTextEditorInsets() + textView.string = panel.textContent + panel.attachTextView(textView) + + scrollView.documentView = textView + Self.applyTheme(to: scrollView, backgroundColor: themeBackgroundColor, foregroundColor: themeForegroundColor) + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.panel = panel + scrollView.isHidden = !isVisibleInUI + Self.applyTheme(to: scrollView, backgroundColor: themeBackgroundColor, foregroundColor: themeForegroundColor) + guard let textView = scrollView.documentView as? SavingTextView else { return } + textView.panel = panel + textView.applyFilePreviewTextEditorInsets() + panel.attachTextView(textView) + guard textView.string != panel.textContent else { return } + context.coordinator.isApplyingPanelUpdate = true + textView.string = panel.textContent + context.coordinator.isApplyingPanelUpdate = false + } + + private static func applyTheme( + to scrollView: NSScrollView, + backgroundColor: NSColor, + foregroundColor: NSColor + ) { + scrollView.backgroundColor = backgroundColor + scrollView.contentView.backgroundColor = backgroundColor + if let textView = scrollView.documentView as? NSTextView { + textView.backgroundColor = backgroundColor + textView.textColor = foregroundColor + textView.insertionPointColor = foregroundColor + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var panel: FilePreviewPanel + var isApplyingPanelUpdate = false + + init(panel: FilePreviewPanel) { + self.panel = panel + } + + deinit {} + + func textDidChange(_ notification: Notification) { + guard !isApplyingPanelUpdate, + let textView = notification.object as? NSTextView else { return } + panel.updateTextContent(textView.string) + } + } +} + +enum FilePreviewTextEditorLayout { + static let textContainerInset = NSSize(width: 12, height: 10) + static let lineFragmentPadding: CGFloat = 0 +} + +extension NSTextView { + func applyFilePreviewTextEditorInsets() { + let targetInset = FilePreviewTextEditorLayout.textContainerInset + if textContainerInset.width != targetInset.width || textContainerInset.height != targetInset.height { + textContainerInset = targetInset + } + if textContainer?.lineFragmentPadding != FilePreviewTextEditorLayout.lineFragmentPadding { + textContainer?.lineFragmentPadding = FilePreviewTextEditorLayout.lineFragmentPadding + } + } +} + +final class SavingTextView: NSTextView { + private static let defaultPreviewFontSize: CGFloat = 13 + private static let minimumPreviewFontSize: CGFloat = 8 + private static let maximumPreviewFontSize: CGFloat = 36 + + weak var panel: FilePreviewPanel? + private var previewFontSize: CGFloat = 13 + private var pendingSaveShortcutChordPrefix: ShortcutStroke? + + deinit {} + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + applyFilePreviewTextEditorInsets() + panel?.retryPendingFocus() + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.type == .keyDown else { + return super.performKeyEquivalent(with: event) + } + guard let shouldSave = saveShortcutMatch(for: event) else { + return super.performKeyEquivalent(with: event) + } + if shouldSave { + panel?.saveTextContent() + } + return true + } + + override func magnify(with event: NSEvent) { + let factor = 1.0 + event.magnification + guard factor.isFinite, factor > 0 else { return } + adjustPreviewFontSize(by: factor) + } + + override func scrollWheel(with event: NSEvent) { + guard FilePreviewInteraction.hasZoomModifier(event) else { + super.scrollWheel(with: event) + return + } + adjustPreviewFontSize(by: FilePreviewInteraction.zoomFactor(forScroll: event)) + } + + override func smartMagnify(with event: NSEvent) { + if previewFontSize == Self.defaultPreviewFontSize { + setPreviewFontSize(18) + } else { + setPreviewFontSize(Self.defaultPreviewFontSize) + } + } + + private func adjustPreviewFontSize(by factor: CGFloat) { + setPreviewFontSize(previewFontSize * factor) + } + + private func setPreviewFontSize(_ nextFontSize: CGFloat) { + let clamped = min(max(nextFontSize, Self.minimumPreviewFontSize), Self.maximumPreviewFontSize) + guard clamped.isFinite else { return } + previewFontSize = clamped + let nextFont = NSFont.monospacedSystemFont(ofSize: clamped, weight: .regular) + font = nextFont + typingAttributes[.font] = nextFont + } + + private func saveShortcutMatch(for event: NSEvent) -> Bool? { + let shortcut = KeyboardShortcutSettings.shortcut(for: .saveFilePreview) + guard shortcut.hasChord else { + pendingSaveShortcutChordPrefix = nil + return shortcut.matches(event: event) ? true : nil + } + + if let pendingPrefix = pendingSaveShortcutChordPrefix { + pendingSaveShortcutChordPrefix = nil + guard pendingPrefix == shortcut.firstStroke, + let secondStroke = shortcut.secondStroke else { + return nil + } + return secondStroke.matches(event: event) ? true : nil + } + + if shortcut.firstStroke.matches(event: event) { + pendingSaveShortcutChordPrefix = shortcut.firstStroke + return false + } + return nil + } +} diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 764f81cf706b..92c558d32556 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -5,6 +5,7 @@ import Bonsplit /// View that renders the appropriate panel view based on panel type struct PanelContentView: View { let panel: any Panel + let workspaceId: UUID let paneId: PaneID let isFocused: Bool let isSelectedInPane: Bool @@ -18,6 +19,14 @@ struct PanelContentView: View { let onTriggerFlash: () -> Void var body: some View { + renderedPanel + .overlay { + paneDropTargetOverlay + } + } + + @ViewBuilder + private var renderedPanel: some View { switch panel.panelType { case .terminal: if let terminalPanel = panel as? TerminalPanel { @@ -62,9 +71,31 @@ struct PanelContentView: View { isFocused: isFocused, isVisibleInUI: isVisibleInUI, portalPriority: portalPriority, + appearance: appearance, onRequestPanelFocus: onRequestPanelFocus ) } } } + + @ViewBuilder + private var paneDropTargetOverlay: some View { + if shouldInstallPaneDropTarget { + PaneDropTargetRepresentable(dropContext: PaneDropContext( + workspaceId: workspaceId, + panelId: panel.id, + paneId: paneId + )) + } + } + + private var shouldInstallPaneDropTarget: Bool { + guard isVisibleInUI else { return false } + switch panel.panelType { + case .markdown, .filePreview: + return true + case .terminal, .browser: + return false + } + } } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 32820d282c08..250bbb6de459 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -45,12 +45,19 @@ struct TerminalPanelView: View { /// Shared appearance settings for panels struct PanelAppearance { + let backgroundColor: NSColor + let foregroundColor: NSColor let dividerColor: Color let unfocusedOverlayNSColor: NSColor let unfocusedOverlayOpacity: Double static func fromConfig(_ config: GhosttyConfig) -> PanelAppearance { PanelAppearance( + backgroundColor: GhosttyBackgroundTheme.color( + backgroundColor: config.backgroundColor, + opacity: config.backgroundOpacity + ), + foregroundColor: config.foregroundColor, dividerColor: Color(nsColor: config.resolvedSplitDividerColor), unfocusedOverlayNSColor: config.unfocusedSplitOverlayFill, unfocusedOverlayOpacity: config.unfocusedSplitOverlayOpacity diff --git a/Sources/TerminalPaneDropTargetView.swift b/Sources/TerminalPaneDropTargetView.swift index 9d7a058a0945..b22bf4218362 100644 --- a/Sources/TerminalPaneDropTargetView.swift +++ b/Sources/TerminalPaneDropTargetView.swift @@ -1,14 +1,17 @@ import AppKit import Bonsplit import Foundation +import SwiftUI -struct TerminalPaneDropContext: Equatable { +struct PaneDropContext: Equatable { let workspaceId: UUID let panelId: UUID let paneId: PaneID } -struct TerminalPaneDragTransfer: Equatable { +typealias TerminalPaneDropContext = PaneDropContext + +struct PaneDragTransfer: Equatable { let tabId: UUID let sourcePaneId: UUID let sourceProcessId: Int32 @@ -17,7 +20,7 @@ struct TerminalPaneDragTransfer: Equatable { sourceProcessId == Int32(ProcessInfo.processInfo.processIdentifier) } - static func decode(from pasteboard: NSPasteboard) -> TerminalPaneDragTransfer? { + static func decode(from pasteboard: NSPasteboard) -> PaneDragTransfer? { if let data = pasteboard.data(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) { return decode(from: data) } @@ -27,7 +30,7 @@ struct TerminalPaneDragTransfer: Equatable { return nil } - static func decode(from data: Data) -> TerminalPaneDragTransfer? { + static func decode(from data: Data) -> PaneDragTransfer? { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let tab = json["tab"] as? [String: Any], let tabIdRaw = tab["id"] as? String, @@ -38,7 +41,7 @@ struct TerminalPaneDragTransfer: Equatable { } let sourceProcessId = (json["sourceProcessId"] as? NSNumber)?.int32Value ?? -1 - return TerminalPaneDragTransfer( + return PaneDragTransfer( tabId: tabId, sourcePaneId: sourcePaneId, sourceProcessId: sourceProcessId @@ -46,7 +49,9 @@ struct TerminalPaneDragTransfer: Equatable { } } -enum TerminalPaneDropRouting { +typealias TerminalPaneDragTransfer = PaneDragTransfer + +enum PaneDropRouting { static func zone(for location: CGPoint, in size: CGSize) -> DropZone { let edgeRatio: CGFloat = 0.25 let horizontalEdge = max(80, size.width * edgeRatio) @@ -64,12 +69,51 @@ enum TerminalPaneDropRouting { return .center } } + + static func filePreviewDestination( + targetPane paneId: PaneID, + zone: DropZone + ) -> BonsplitController.ExternalTabDropRequest.Destination { + switch zone { + case .center: + return .insert(targetPane: paneId, targetIndex: nil) + case .left: + return .split(targetPane: paneId, orientation: .horizontal, insertFirst: true) + case .right: + return .split(targetPane: paneId, orientation: .horizontal, insertFirst: false) + case .top: + return .split(targetPane: paneId, orientation: .vertical, insertFirst: true) + case .bottom: + return .split(targetPane: paneId, orientation: .vertical, insertFirst: false) + } + } + + static func overlayFrame(for zone: DropZone, in bounds: CGRect) -> CGRect { + let midX = bounds.midX + let midY = bounds.midY + + switch zone { + case .center: + return bounds.insetBy(dx: 10, dy: 10) + case .left: + return CGRect(x: bounds.minX + 8, y: bounds.minY + 8, width: max(0, midX - bounds.minX - 12), height: max(0, bounds.height - 16)) + case .right: + return CGRect(x: midX + 4, y: bounds.minY + 8, width: max(0, bounds.maxX - midX - 12), height: max(0, bounds.height - 16)) + case .top: + return CGRect(x: bounds.minX + 8, y: midY + 4, width: max(0, bounds.width - 16), height: max(0, bounds.maxY - midY - 12)) + case .bottom: + return CGRect(x: bounds.minX + 8, y: bounds.minY + 8, width: max(0, bounds.width - 16), height: max(0, midY - bounds.minY - 12)) + } + } } -final class TerminalPaneDropTargetView: NSView { +typealias TerminalPaneDropRouting = PaneDropRouting + +final class PaneDropTargetView: NSView { weak var hostedView: GhosttySurfaceScrollView? - var dropContext: TerminalPaneDropContext? + var dropContext: PaneDropContext? private var activeZone: DropZone? + private let dropZoneOverlayView = NSView(frame: .zero) #if DEBUG private var lastHitTestSignature: String? #endif @@ -78,7 +122,11 @@ final class TerminalPaneDropTargetView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) - registerForDraggedTypes([DragOverlayRoutingPolicy.bonsplitTabTransferType]) + registerForDraggedTypes([ + DragOverlayRoutingPolicy.bonsplitTabTransferType, + .fileURL, + ]) + setupDropZoneOverlayView() } @available(*, unavailable) @@ -86,13 +134,30 @@ final class TerminalPaneDropTargetView: NSView { nil } + override func layout() { + super.layout() + updateStandaloneDropZoneOverlay() + } + static func shouldCaptureHitTesting( pasteboardTypes: [NSPasteboard.PasteboardType]?, eventType: NSEvent.EventType? ) -> Bool { - guard DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) else { return false } + let hasTabTransfer = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + let hasFileURL = DragOverlayRoutingPolicy.hasFileURL(pasteboardTypes) + guard hasTabTransfer || hasFileURL else { return false } guard let eventType else { return false } + if hasFileURL, !hasTabTransfer { + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged, + .leftMouseUp, .rightMouseUp, .otherMouseUp: + return true + default: + return false + } + } + switch eventType { case .cursorUpdate, .mouseEntered, @@ -101,6 +166,9 @@ final class TerminalPaneDropTargetView: NSView { .leftMouseDragged, .rightMouseDragged, .otherMouseDragged, + .leftMouseUp, + .rightMouseUp, + .otherMouseUp, .appKitDefined, .applicationDefined, .systemDefined, @@ -147,27 +215,56 @@ final class TerminalPaneDropTargetView: NSView { } guard let dropContext, - let transfer = TerminalPaneDragTransfer.decode(from: sender.draggingPasteboard), - transfer.isFromCurrentProcess, let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else { #if DEBUG - cmuxDebugLog("terminal.paneDrop.perform allowed=0 reason=missingTransfer") + cmuxDebugLog("terminal.paneDrop.perform allowed=0 reason=missingContext") #endif return false } - let zone = resolvedZone(for: sender, transfer: transfer, context: dropContext, workspace: workspace) - let handled = workspace.performPortalPaneDrop( - tabId: transfer.tabId, - sourcePaneId: transfer.sourcePaneId, - targetPane: dropContext.paneId, - zone: zone - ) + if let transfer = PaneDragTransfer.decode(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess { + let zone = resolvedZone(for: sender, transfer: transfer, context: dropContext, workspace: workspace) + let handled = workspace.performPortalPaneDrop( + tabId: transfer.tabId, + sourcePaneId: transfer.sourcePaneId, + targetPane: dropContext.paneId, + zone: zone + ) +#if DEBUG + cmuxDebugLog( + "terminal.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone) " + + "pane=\(dropContext.paneId.id.uuidString.prefix(5)) handled=\(handled ? 1 : 0)" + ) +#endif + return handled + } + + let urls = DragOverlayRoutingPolicy.fileURLs(from: sender.draggingPasteboard) + guard !urls.isEmpty else { +#if DEBUG + cmuxDebugLog( + "terminal.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "reason=missingTransferAndFiles" + ) +#endif + return false + } + + let zone = fileDropZone(for: sender) + let handled = workspace.handleExternalFileDrop(BonsplitController.ExternalFileDropRequest( + urls: urls, + destination: PaneDropRouting.filePreviewDestination( + targetPane: dropContext.paneId, + zone: zone + ) + )) #if DEBUG cmuxDebugLog( "terminal.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " + - "tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone) " + - "pane=\(dropContext.paneId.id.uuidString.prefix(5)) handled=\(handled ? 1 : 0)" + "fileURLs=\(urls.count) zone=\(zone) pane=\(dropContext.paneId.id.uuidString.prefix(5)) " + + "handled=\(handled ? 1 : 0)" ) #endif return handled @@ -181,38 +278,58 @@ final class TerminalPaneDropTargetView: NSView { } guard let dropContext, - let transfer = TerminalPaneDragTransfer.decode(from: sender.draggingPasteboard), - transfer.isFromCurrentProcess, let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else { clearDragState(phase: "\(phase).reject") return [] } - let zone = resolvedZone( - for: sender, - transfer: transfer, - context: dropContext, - workspace: workspace - ) - activeZone = zone - hostedView?.setDropZoneOverlay(zone: zone) + if let transfer = PaneDragTransfer.decode(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess { + let zone = resolvedZone( + for: sender, + transfer: transfer, + context: dropContext, + workspace: workspace + ) + setActiveDropZone(zone) +#if DEBUG + cmuxDebugLog( + "terminal.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)" + ) +#endif + return .move + } + + guard !DragOverlayRoutingPolicy.fileURLs(from: sender.draggingPasteboard).isEmpty else { + clearDragState(phase: "\(phase).reject") + return [] + } + + let zone = fileDropZone(for: sender) + setActiveDropZone(zone) #if DEBUG cmuxDebugLog( "terminal.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " + - "tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)" + "fileURL=1 zone=\(zone)" ) #endif - return .move + return .copy + } + + private func fileDropZone(for sender: any NSDraggingInfo) -> DropZone { + let location = convert(sender.draggingLocation, from: nil) + return PaneDropRouting.zone(for: location, in: bounds.size) } private func resolvedZone( for sender: any NSDraggingInfo, - transfer: TerminalPaneDragTransfer, - context: TerminalPaneDropContext, + transfer: PaneDragTransfer, + context: PaneDropContext, workspace: Workspace ) -> DropZone { let location = convert(sender.draggingLocation, from: nil) - let proposedZone = TerminalPaneDropRouting.zone(for: location, in: bounds.size) + let proposedZone = PaneDropRouting.zone(for: location, in: bounds.size) return workspace.portalPaneDropZone( tabId: transfer.tabId, sourcePaneId: transfer.sourcePaneId, @@ -228,10 +345,39 @@ final class TerminalPaneDropTargetView: NSView { .result } + private func setupDropZoneOverlayView() { + dropZoneOverlayView.wantsLayer = true + dropZoneOverlayView.layer?.backgroundColor = cmuxAccentNSColor().withAlphaComponent(0.25).cgColor + dropZoneOverlayView.layer?.borderColor = cmuxAccentNSColor().cgColor + dropZoneOverlayView.layer?.borderWidth = 2 + dropZoneOverlayView.layer?.cornerRadius = 8 + dropZoneOverlayView.isHidden = true + dropZoneOverlayView.autoresizingMask = [] + addSubview(dropZoneOverlayView) + } + + private func setActiveDropZone(_ zone: DropZone?) { + activeZone = zone + if let hostedView { + hostedView.setDropZoneOverlay(zone: zone) + dropZoneOverlayView.isHidden = true + } else { + updateStandaloneDropZoneOverlay() + } + } + + private func updateStandaloneDropZoneOverlay() { + guard hostedView == nil, let activeZone else { + dropZoneOverlayView.isHidden = true + return + } + dropZoneOverlayView.frame = PaneDropRouting.overlayFrame(for: activeZone, in: bounds) + dropZoneOverlayView.isHidden = false + } + private func clearDragState(phase: String) { guard activeZone != nil else { return } - activeZone = nil - hostedView?.setDropZoneOverlay(zone: nil) + setActiveDropZone(nil) #if DEBUG if let dropContext { cmuxDebugLog( @@ -248,11 +394,13 @@ final class TerminalPaneDropTargetView: NSView { eventType: NSEvent.EventType? ) { let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) - guard hasTransferType || capture else { return } + let hasFileURL = DragOverlayRoutingPolicy.hasFileURL(pasteboardTypes) + guard hasTransferType || hasFileURL || capture else { return } let signature = [ capture ? "1" : "0", hasTransferType ? "1" : "0", + hasFileURL ? "1" : "0", String(describing: dropContext != nil), eventType.map { String($0.rawValue) } ?? "nil", ].joined(separator: "|") @@ -262,9 +410,28 @@ final class TerminalPaneDropTargetView: NSView { let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-" cmuxDebugLog( "terminal.paneDrop.hitTest capture=\(capture ? 1 : 0) " + - "hasTransfer=\(hasTransferType ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " + + "hasTransfer=\(hasTransferType ? 1 : 0) hasFileURL=\(hasFileURL ? 1 : 0) " + + "context=\(dropContext != nil ? 1 : 0) " + "event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)" ) } #endif } + +typealias TerminalPaneDropTargetView = PaneDropTargetView + +struct PaneDropTargetRepresentable: NSViewRepresentable { + let dropContext: PaneDropContext? + + func makeNSView(context: Context) -> PaneDropTargetView { + PaneDropTargetView(frame: .zero) + } + + func updateNSView(_ nsView: PaneDropTargetView, context: Context) { + nsView.dropContext = dropContext + nsView.hostedView = nil + if dropContext == nil { + nsView.draggingExited(nil) + } + } +} diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index d95f644980ab..c0ba5066e47e 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1726,42 +1726,34 @@ final class WindowTerminalPortal: NSObject { } #endif - func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? { + private func hostedScrollViewAtWindowPoint(_ windowPoint: NSPoint) -> (view: GhosttySurfaceScrollView, point: NSPoint)? { guard ensureInstalled() else { return nil } let point = hostView.convert(windowPoint, from: nil) - // Restrict hit-testing to currently mapped entries so stale detached views - // can't steal file-drop/mouse routing. for subview in hostView.subviews.reversed() { - guard let hostedView = subview as? GhosttySurfaceScrollView else { continue } - let hostedId = ObjectIdentifier(hostedView) - guard entriesByHostedId[hostedId] != nil else { continue } - guard !hostedView.isHidden else { continue } - guard hostedView.frame.contains(point) else { continue } - let localPoint = hostedView.convert(point, from: hostView) - return hostedView.hitTest(localPoint) ?? hostedView + guard let hostedView = subview as? GhosttySurfaceScrollView, + entriesByHostedId[ObjectIdentifier(hostedView)] != nil, + !hostedView.isHidden, + hostedView.frame.contains(point) else { continue } + return (hostedView, hostedView.convert(point, from: hostView)) } return nil } - func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { - guard ensureInstalled() else { return nil } - let point = hostView.convert(windowPoint, from: nil) + func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? { + guard let hit = hostedScrollViewAtWindowPoint(windowPoint) else { return nil } + return hit.view.hitTest(hit.point) ?? hit.view + } - for subview in hostView.subviews.reversed() { - guard let hostedView = subview as? GhosttySurfaceScrollView else { continue } - let hostedId = ObjectIdentifier(hostedView) - guard entriesByHostedId[hostedId] != nil else { continue } - guard !hostedView.isHidden else { continue } - guard hostedView.frame.contains(point) else { continue } - let localPoint = hostedView.convert(point, from: hostView) - if let terminal = hostedView.terminalViewForDrop(at: localPoint) { - return terminal - } - } + func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + guard let hit = hostedScrollViewAtWindowPoint(windowPoint) else { return nil } + return hit.view.terminalViewForDrop(at: hit.point) + } - return nil + func terminalPaneDropTargetAtWindowPoint(_ windowPoint: NSPoint) -> TerminalPaneDropTargetView? { + guard let hit = hostedScrollViewAtWindowPoint(windowPoint) else { return nil } + return hit.view.paneDropTargetForDrop(at: hit.point) } } @@ -2097,6 +2089,14 @@ enum TerminalWindowPortalRegistry { return portal.terminalViewAtWindowPoint(windowPoint) } + static func terminalPaneDropTargetAtWindowPoint( + _ windowPoint: NSPoint, + in window: NSWindow + ) -> TerminalPaneDropTargetView? { + let portal = portal(for: window) + return portal.terminalPaneDropTargetAtWindowPoint(windowPoint) + } + #if DEBUG static func debugPortalCount() -> Int { portalsByWindowId.count diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 58b7d9ab2aad..2cc499872dcb 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -383,12 +383,16 @@ extension Workspace { !directory.isEmpty { return directory } + if let restorableDirectory = effectiveRestorableAgent?.workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !restorableDirectory.isEmpty { + return restorableDirectory + } if let terminalPanel = panel as? TerminalPanel, let requestedDirectory = terminalPanel.requestedWorkingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !requestedDirectory.isEmpty { return requestedDirectory } - return effectiveRestorableAgent?.workingDirectory + return nil }() let isPinned = pinnedPanelIds.contains(panelId) let isManuallyUnread = manualUnreadPanelIds.contains(panelId) @@ -12568,7 +12572,7 @@ final class Workspace: Identifiable, ObservableObject { } } - private func handleExternalFileDrop(_ request: BonsplitController.ExternalFileDropRequest) -> Bool { + func handleExternalFileDrop(_ request: BonsplitController.ExternalFileDropRequest) -> Bool { let entries = request.urls .filter(\.isFileURL) .map { diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 3c1f5a8ee729..a6e33ac58352 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -200,7 +200,6 @@ struct WorkspaceContentView: View { // AppKit-backed views can still intercept drags. Disable drop acceptance for them. let _ = { workspace.bonsplitController.isInteractive = isWorkspaceInputActive }() - // Wire up file drop handling so bonsplit's PaneDragContainerView can forward // Finder file drops to the correct terminal panel. let _ = { @@ -234,6 +233,7 @@ struct WorkspaceContentView: View { ) PanelContentView( panel: panel, + workspaceId: workspace.id, paneId: paneId, isFocused: isFocused, isSelectedInPane: isSelectedInPane, diff --git a/cmuxTests/FileDropOverlayViewTests.swift b/cmuxTests/FileDropOverlayViewTests.swift new file mode 100644 index 000000000000..168749eea0c1 --- /dev/null +++ b/cmuxTests/FileDropOverlayViewTests.swift @@ -0,0 +1,257 @@ +import AppKit +import ObjectiveC.runtime +import SwiftUI +import WebKit +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class FileDropOverlayViewTests: XCTestCase { + private func makeContentViewWindow(windowId: UUID = UUID()) -> NSWindow { + _ = NSApplication.shared + + let root = ContentView(updateViewModel: UpdateViewModel(), windowId: windowId) + .environmentObject(TabManager()) + .environmentObject(TerminalNotificationStore.shared) + .environmentObject(SidebarState()) + .environmentObject(SidebarSelectionState()) + .environmentObject(FileExplorerState()) + .environmentObject(CmuxConfigStore()) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 340), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.contentView = MainWindowHostingView(rootView: root) + return window + } + + private func fileDropOverlays(in root: NSView?) -> [FileDropOverlayView] { + guard let root else { return [] } + + var overlays: [FileDropOverlayView] = [] + if let overlay = root as? FileDropOverlayView { + overlays.append(overlay) + } + for subview in root.subviews { + overlays.append(contentsOf: fileDropOverlays(in: subview)) + } + return overlays + } + + private final class DragSpyWebView: WKWebView { + var dragCalls: [String] = [] + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + dragCalls.append("entered") + return .copy + } + + override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + dragCalls.append("prepare") + return true + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + dragCalls.append("perform") + return true + } + + override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + dragCalls.append("conclude") + } + } + + private final class MockDraggingInfo: NSObject, NSDraggingInfo { + let draggingDestinationWindow: NSWindow? + let draggingSourceOperationMask: NSDragOperation + let draggingLocation: NSPoint + let draggedImageLocation: NSPoint + let draggedImage: NSImage? + nonisolated(unsafe) let draggingPasteboard: NSPasteboard + nonisolated(unsafe) let draggingSource: Any? + let draggingSequenceNumber: Int + var draggingFormation: NSDraggingFormation = .default + var animatesToDestination = false + var numberOfValidItemsForDrop = 1 + let springLoadingHighlight: NSSpringLoadingHighlight = .none + + init( + window: NSWindow, + location: NSPoint, + pasteboard: NSPasteboard, + sourceOperationMask: NSDragOperation = .copy, + draggingSource: Any? = nil, + sequenceNumber: Int = 1 + ) { + self.draggingDestinationWindow = window + self.draggingSourceOperationMask = sourceOperationMask + self.draggingLocation = location + self.draggedImageLocation = location + self.draggedImage = nil + self.draggingPasteboard = pasteboard + self.draggingSource = draggingSource + self.draggingSequenceNumber = sequenceNumber + } + + 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 realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + + func testContentViewInstallsSingleFileDropOverlayAcrossRepeatedLayouts() { + let window = makeContentViewWindow() + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + realizeWindowLayout(window) + realizeWindowLayout(window) + realizeWindowLayout(window) + + guard let themeFrame = window.contentView?.superview else { + XCTFail("Expected theme frame") + return + } + + let overlays = fileDropOverlays(in: themeFrame) + XCTAssertEqual( + overlays.count, + 1, + "ContentView should install exactly one FileDropOverlayView even after repeated layout passes" + ) + XCTAssertTrue( + (objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView) === overlays.first, + "The window-associated file-drop overlay should match the single installed view" + ) + } + + func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + realizeWindowLayout(window) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + defer { BrowserWindowPortalRegistry.detach(webView: webView) } + + let overlay = FileDropOverlayView(frame: container.bounds) + overlay.autoresizingMask = [.width, .height] + container.addSubview(overlay, positioned: .above, relativeTo: nil) + + let point = anchor.convert( + NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY), + to: nil + ) + XCTAssertTrue( + overlay.webViewUnderPoint(point) === webView, + "File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView" + ) + } + + func testOverlayDelegatesBrowserFileDragLifecycleToPortalHostedWebView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + realizeWindowLayout(window) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let anchor = NSView(frame: NSRect(x: 52, y: 44, width: 210, height: 140)) + contentView.addSubview(anchor) + + let webView = DragSpyWebView(frame: .zero, configuration: WKWebViewConfiguration()) + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + defer { BrowserWindowPortalRegistry.detach(webView: webView) } + + let overlay = FileDropOverlayView(frame: container.bounds) + overlay.autoresizingMask = [.width, .height] + container.addSubview(overlay, positioned: .above, relativeTo: nil) + + let pasteboard = NSPasteboard(name: NSPasteboard.Name("cmux.test.drag.\(UUID().uuidString)")) + pasteboard.clearContents() + XCTAssertTrue( + pasteboard.writeObjects([URL(fileURLWithPath: "/tmp/upload.mov") as NSURL]), + "Expected file URL drag payload" + ) + + let dropPoint = anchor.convert( + NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY), + to: nil + ) + let dragInfo = MockDraggingInfo( + window: window, + location: dropPoint, + pasteboard: pasteboard + ) + + XCTAssertEqual(overlay.draggingEntered(dragInfo), .copy) + XCTAssertTrue(overlay.prepareForDragOperation(dragInfo)) + XCTAssertTrue(overlay.performDragOperation(dragInfo)) + overlay.concludeDragOperation(dragInfo) + + XCTAssertEqual( + webView.dragCalls, + ["entered", "prepare", "perform", "conclude"], + "Finder file drops over browser panes should still reach the portal-hosted WKWebView" + ) + } +} diff --git a/cmuxTests/FinderFileDropRegressionTests.swift b/cmuxTests/FinderFileDropRegressionTests.swift index f6f4839c5087..c903fb210fdf 100644 --- a/cmuxTests/FinderFileDropRegressionTests.swift +++ b/cmuxTests/FinderFileDropRegressionTests.swift @@ -20,7 +20,7 @@ final class FinderFileDropRegressionTests: XCTestCase { return try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) } - func testOverlayCapturesExternalFileDropsButNotInternalPreviewDrags() { + func testOverlayCapturesFileURLDropsIncludingLocalPaneDrags() { XCTAssertTrue( DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( pasteboardTypes: [.fileURL], @@ -35,26 +35,26 @@ final class FinderFileDropRegressionTests: XCTestCase { ) ) - XCTAssertFalse( + XCTAssertTrue( DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( pasteboardTypes: [.fileURL, DragOverlayRoutingPolicy.filePreviewTransferType], hasLocalDraggingSource: true ), - "Internal file-preview drags carry a dedicated cmux transfer type and should stay on pane/file-preview routing" + "Internal file-preview drags still need the shared pane drop destination so they can split or insert like Finder files" ) - XCTAssertFalse( + XCTAssertTrue( DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( pasteboardTypes: [.fileURL, DragOverlayRoutingPolicy.bonsplitTabTransferType], hasLocalDraggingSource: true ), - "Bonsplit tab drags can also advertise file URLs and must not be hijacked by the file-drop overlay" + "Bonsplit tab drags use the same pane drop destination while tab-bar hit testing still defers to Bonsplit" ) - XCTAssertFalse( + XCTAssertTrue( DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( pasteboardTypes: [.fileURL], hasLocalDraggingSource: true ), - "Unknown local file drags should stay off the root overlay unless they are proven external" + "File explorer drags are local file drags and must still reach the shared pane drop destination" ) } diff --git a/cmuxTests/PortalTabDragRoutingTests.swift b/cmuxTests/PortalTabDragRoutingTests.swift index 909fc7a34074..3cbeb018d094 100644 --- a/cmuxTests/PortalTabDragRoutingTests.swift +++ b/cmuxTests/PortalTabDragRoutingTests.swift @@ -168,15 +168,38 @@ final class PortalTabDragRoutingTests: XCTestCase { ) } - func testTerminalPaneDropTargetIgnoresExternalFileImageAndBrowserDrags() { + func testTerminalPaneDropTargetCapturesFinderFilesButIgnoresBrowserPayloads() { + XCTAssertTrue( + TerminalPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [.fileURL], + eventType: .leftMouseDragged + ) + ) + XCTAssertTrue( + TerminalPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [.fileURL], + eventType: .leftMouseUp + ) + ) + XCTAssertTrue( + TerminalPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [.fileURL, .png], + eventType: .leftMouseDragged + ) + ) + XCTAssertTrue( + TerminalPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [DragOverlayRoutingPolicy.filePreviewTransferType, DragOverlayRoutingPolicy.bonsplitTabTransferType, .fileURL], + eventType: .leftMouseUp + ) + ) + let externalPayloads: [[NSPasteboard.PasteboardType]] = [ - [.fileURL], [.URL], [.png], [.tiff], [.html], [.string], - [.fileURL, .png], ] for pasteboardTypes in externalPayloads { @@ -189,4 +212,40 @@ final class PortalTabDragRoutingTests: XCTestCase { ) } } + + func testPaneDropRoutingMapsFileDropsToSharedBonsplitDestinations() { + let paneId = PaneID() + + if case let .insert(targetPane, targetIndex) = PaneDropRouting.filePreviewDestination( + targetPane: paneId, + zone: .center + ) { + XCTAssertEqual(targetPane, paneId) + XCTAssertNil(targetIndex) + } else { + XCTFail("Center drops should insert into the hovered pane") + } + + if case let .split(targetPane, orientation, insertFirst) = PaneDropRouting.filePreviewDestination( + targetPane: paneId, + zone: .left + ) { + XCTAssertEqual(targetPane, paneId) + XCTAssertEqual(orientation, .horizontal) + XCTAssertTrue(insertFirst) + } else { + XCTFail("Left drops should use Bonsplit horizontal split routing") + } + + if case let .split(targetPane, orientation, insertFirst) = PaneDropRouting.filePreviewDestination( + targetPane: paneId, + zone: .bottom + ) { + XCTAssertEqual(targetPane, paneId) + XCTAssertEqual(orientation, .vertical) + XCTAssertFalse(insertFirst) + } else { + XCTFail("Bottom drops should use Bonsplit vertical split routing") + } + } } diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index d2781e656acc..9063197cee94 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1854,7 +1854,7 @@ final class FilePreviewDragPasteboardWriterTests: XCTestCase { super.tearDown() } - func testRegistrationIsLazyAndDiscardedFromDragPasteboard() throws { + func testRegistrationIsPreparedWhenDragTypesAreRequested() throws { let fileURL = URL(fileURLWithPath: "/tmp/example.txt").standardizedFileURL let writer = FilePreviewDragPasteboardWriter( filePath: fileURL.path, @@ -1863,7 +1863,10 @@ final class FilePreviewDragPasteboardWriterTests: XCTestCase { let dragPasteboard = NSPasteboard(name: .drag) XCTAssertNil(FilePreviewDragPasteboardWriter.dragID(from: dragPasteboard)) - XCTAssertTrue(writer.writableTypes(for: dragPasteboard).contains(.fileURL)) + let writableTypes = writer.writableTypes(for: dragPasteboard) + XCTAssertTrue(writableTypes.contains(.fileURL)) + let preparedDragID = try XCTUnwrap(FilePreviewDragPasteboardWriter.dragID(from: dragPasteboard)) + XCTAssertTrue(FilePreviewDragRegistry.shared.contains(id: preparedDragID)) XCTAssertEqual( writer.pasteboardPropertyList(forType: .fileURL) as? String, fileURL.absoluteString @@ -1873,6 +1876,7 @@ final class FilePreviewDragPasteboardWriterTests: XCTestCase { writer.pasteboardPropertyList(forType: DragOverlayRoutingPolicy.filePreviewTransferType) as? Data ) let dragID = try XCTUnwrap(FilePreviewDragPasteboardWriter.dragID(from: filePreviewData)) + XCTAssertEqual(dragID, preparedDragID) XCTAssertTrue(FilePreviewDragRegistry.shared.contains(id: dragID)) let bonsplitData = try XCTUnwrap( @@ -2333,253 +2337,6 @@ final class BonsplitTabDragPayloadTests: XCTestCase { } } - -@MainActor -final class FileDropOverlayViewTests: XCTestCase { - private func makeContentViewWindow(windowId: UUID = UUID()) -> NSWindow { - _ = NSApplication.shared - - let root = ContentView(updateViewModel: UpdateViewModel(), windowId: windowId) - .environmentObject(TabManager()) - .environmentObject(TerminalNotificationStore.shared) - .environmentObject(SidebarState()) - .environmentObject(SidebarSelectionState()) - .environmentObject(FileExplorerState()) - .environmentObject(CmuxConfigStore()) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 340), - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false - ) - window.contentView = MainWindowHostingView(rootView: root) - return window - } - - private func fileDropOverlays(in root: NSView?) -> [FileDropOverlayView] { - guard let root else { return [] } - - var overlays: [FileDropOverlayView] = [] - if let overlay = root as? FileDropOverlayView { - overlays.append(overlay) - } - for subview in root.subviews { - overlays.append(contentsOf: fileDropOverlays(in: subview)) - } - return overlays - } - - private final class DragSpyWebView: WKWebView { - var dragCalls: [String] = [] - - override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - dragCalls.append("entered") - return .copy - } - - override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { - dragCalls.append("prepare") - return true - } - - override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - dragCalls.append("perform") - return true - } - - override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { - dragCalls.append("conclude") - } - } - - private final class MockDraggingInfo: NSObject, NSDraggingInfo { - let draggingDestinationWindow: NSWindow? - let draggingSourceOperationMask: NSDragOperation - let draggingLocation: NSPoint - let draggedImageLocation: NSPoint - let draggedImage: NSImage? - nonisolated(unsafe) let draggingPasteboard: NSPasteboard - nonisolated(unsafe) let draggingSource: Any? - let draggingSequenceNumber: Int - var draggingFormation: NSDraggingFormation = .default - var animatesToDestination = false - var numberOfValidItemsForDrop = 1 - let springLoadingHighlight: NSSpringLoadingHighlight = .none - - init( - window: NSWindow, - location: NSPoint, - pasteboard: NSPasteboard, - sourceOperationMask: NSDragOperation = .copy, - draggingSource: Any? = nil, - sequenceNumber: Int = 1 - ) { - self.draggingDestinationWindow = window - self.draggingSourceOperationMask = sourceOperationMask - self.draggingLocation = location - self.draggedImageLocation = location - self.draggedImage = nil - self.draggingPasteboard = pasteboard - self.draggingSource = draggingSource - self.draggingSequenceNumber = sequenceNumber - } - - 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 realizeWindowLayout(_ window: NSWindow) { - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - window.contentView?.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - window.contentView?.layoutSubtreeIfNeeded() - } - - func testContentViewInstallsSingleFileDropOverlayAcrossRepeatedLayouts() { - let window = makeContentViewWindow() - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - - realizeWindowLayout(window) - realizeWindowLayout(window) - realizeWindowLayout(window) - - guard let themeFrame = window.contentView?.superview else { - XCTFail("Expected theme frame") - return - } - - let overlays = fileDropOverlays(in: themeFrame) - XCTAssertEqual( - overlays.count, - 1, - "ContentView should install exactly one FileDropOverlayView even after repeated layout passes" - ) - XCTAssertTrue( - (objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView) === overlays.first, - "The window-associated file-drop overlay should match the single installed view" - ) - } - - func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - realizeWindowLayout(window) - - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - - let overlay = FileDropOverlayView(frame: container.bounds) - overlay.autoresizingMask = [.width, .height] - container.addSubview(overlay, positioned: .above, relativeTo: nil) - - let point = anchor.convert( - NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY), - to: nil - ) - XCTAssertTrue( - overlay.webViewUnderPoint(point) === webView, - "File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView" - ) - } - - func testOverlayForwardsExternalFileDragLifecycleToBrowserWebViews() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - realizeWindowLayout(window) - - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let anchor = NSView(frame: NSRect(x: 52, y: 44, width: 210, height: 140)) - contentView.addSubview(anchor) - - let webView = DragSpyWebView(frame: .zero, configuration: WKWebViewConfiguration()) - BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - defer { BrowserWindowPortalRegistry.detach(webView: webView) } - - let overlay = FileDropOverlayView(frame: container.bounds) - overlay.autoresizingMask = [.width, .height] - container.addSubview(overlay, positioned: .above, relativeTo: nil) - - let pasteboard = NSPasteboard(name: NSPasteboard.Name("cmux.test.drag.\(UUID().uuidString)")) - pasteboard.clearContents() - XCTAssertTrue( - pasteboard.writeObjects([URL(fileURLWithPath: "/tmp/upload.mov") as NSURL]), - "Expected file URL drag payload" - ) - - let dropPoint = anchor.convert( - NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY), - to: nil - ) - let dragInfo = MockDraggingInfo( - window: window, - location: dropPoint, - pasteboard: pasteboard - ) - - XCTAssertEqual(overlay.draggingEntered(dragInfo), .copy) - XCTAssertTrue(overlay.prepareForDragOperation(dragInfo)) - XCTAssertTrue(overlay.performDragOperation(dragInfo)) - overlay.concludeDragOperation(dragInfo) - - XCTAssertEqual( - webView.dragCalls, - ["entered", "prepare", "perform", "conclude"], - "Finder file drops should keep browser uploads working while the root overlay owns the external file drag destination" - ) - } -} - - @MainActor final class MarkdownPanelPointerObserverViewTests: XCTestCase { private func makeWindow() -> NSWindow { diff --git a/vendor/bonsplit b/vendor/bonsplit index 90f4981f6a99..aa4f69723ea1 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 90f4981f6a990a2282b2433a67f32edc89306c36 +Subproject commit aa4f69723ea1a5a59df9dc0c9e7bbdfb1f96abc1