From 93d2fb740018e873c60afac085e5e03426b6701d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 5 May 2026 00:23:07 -0700 Subject: [PATCH 1/4] Fix file preview drop routing --- Sources/ContentView.swift | 135 ++++++++++-- Sources/GhosttyTerminalView.swift | 8 + Sources/Panels/FilePreviewPanel.swift | 50 ++++- Sources/Panels/PanelContentView.swift | 30 +++ Sources/Panels/TerminalPanelView.swift | 7 + Sources/TerminalPaneDropTargetView.swift | 249 ++++++++++++++++++---- Sources/TerminalWindowPortal.swift | 27 +++ Sources/Workspace.swift | 6 +- Sources/WorkspaceContentView.swift | 1 + cmuxTests/PortalTabDragRoutingTests.swift | 65 +++++- cmuxTests/WindowAndDragTests.swift | 20 +- vendor/bonsplit | 2 +- 12 files changed, 520 insertions(+), 80 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 15ad8cb4d03d..becf0d59b70f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -34,15 +34,34 @@ enum DragOverlayRoutingPolicy { return pasteboardTypes.contains(.fileURL) } + static func fileURLs(from pasteboard: NSPasteboard) -> [URL] { + let objects = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) ?? [] + return objects.compactMap { object -> URL? in + if let url = object as? URL { + return url.isFileURL ? url : nil + } + if let url = object as? NSURL { + let bridged = url as URL + return bridged.isFileURL ? bridged : nil + } + return nil + } + } + static func shouldCaptureFileDropDestination( pasteboardTypes: [NSPasteboard.PasteboardType]?, hasLocalDraggingSource: Bool ) -> Bool { - // File URL drops are routed at the Bonsplit pane layer so center/edge - // drop targets stay visible and the host can open previews or splits. + // The window overlay is the stable AppKit drag destination for Finder + // and sidebar file drags. It delegates file drops to pane-level + // Bonsplit targets so split affordances and preview routing stay local + // to the hovered pane. _ = hasLocalDraggingSource guard hasFileURL(pasteboardTypes) else { return false } - return false + return true } static func shouldCaptureFileDropDestination( @@ -141,6 +160,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] = [:] @@ -208,6 +231,9 @@ final class FileDropOverlayView: NSView { ) #endif guard shouldCapture else { return nil } + if shouldDeferFileDropOverlayToBonsplitTabBar(at: point) { + return nil + } return super.hitTest(point) } @@ -307,10 +333,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 { @@ -321,15 +352,17 @@ 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 { @@ -341,7 +374,12 @@ final class FileDropOverlayView: NSView { return webView.prepareForDragOperation(sender) } preparedDragWebView = nil - return hasTerminalTarget + if let paneDropTarget { + preparedPaneDropTarget = paneDropTarget + return true + } + preparedPaneDropTarget = nil + return hasPaneTarget } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { @@ -354,15 +392,18 @@ 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 { @@ -375,6 +416,12 @@ final class FileDropOverlayView: NSView { return webView.performDragOperation(sender) } preparedDragWebView = nil + if let paneDropTarget { + preparedPaneDropTarget = nil + activePaneDropTarget = nil + return paneDropTarget.performDragOperation(sender) + } + preparedPaneDropTarget = nil activeDragWebView = nil guard let terminal else { return false } return terminal.performDragOperation(sender) @@ -384,6 +431,8 @@ final class FileDropOverlayView: NSView { defer { preparedDragWebView = nil activeDragWebView = nil + preparedPaneDropTarget = nil + activePaneDropTarget = nil } guard let sender else { return } guard DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( @@ -394,6 +443,9 @@ final class FileDropOverlayView: NSView { } let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) webView?.concludeDragOperation(sender) + if let paneDropTarget = preparedPaneDropTarget ?? activePaneDropTarget { + paneDropTarget.draggingExited(sender) + } } private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { @@ -405,11 +457,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 { @@ -419,17 +476,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 } @@ -568,13 +633,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 } @@ -582,7 +647,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))" ) } @@ -607,6 +672,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 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index cd8b5620f051..e227d9c3b29e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10880,6 +10880,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..1a2909f9288f 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: @@ -912,6 +928,8 @@ struct FilePreviewPanelView: View { private struct FilePreviewTextEditor: NSViewRepresentable { @ObservedObject var panel: FilePreviewPanel let isVisibleInUI: Bool + let themeBackgroundColor: NSColor + let themeForegroundColor: NSColor func makeCoordinator() -> Coordinator { Coordinator(panel: panel) @@ -924,7 +942,7 @@ private struct FilePreviewTextEditor: NSViewRepresentable { scrollView.hasHorizontalScroller = true scrollView.autohidesScrollers = true scrollView.borderType = .noBorder - scrollView.drawsBackground = false + scrollView.drawsBackground = true let textView = SavingTextView() textView.panel = panel @@ -937,9 +955,7 @@ private struct FilePreviewTextEditor: NSViewRepresentable { textView.usesFindPanel = true textView.usesFontPanel = false textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular) - textView.textColor = .labelColor - textView.backgroundColor = .textBackgroundColor - textView.insertionPointColor = .labelColor + textView.drawsBackground = true textView.minSize = NSSize(width: 0, height: 0) textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.isVerticallyResizable = true @@ -955,12 +971,14 @@ private struct FilePreviewTextEditor: NSViewRepresentable { 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() @@ -971,6 +989,20 @@ private struct FilePreviewTextEditor: NSViewRepresentable { 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 diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 764f81cf706b..648179c1b0f0 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,30 @@ 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 { + 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..9c8cdea247ab 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1763,6 +1763,25 @@ final class WindowTerminalPortal: NSObject { return nil } + + func terminalPaneDropTargetAtWindowPoint(_ windowPoint: NSPoint) -> TerminalPaneDropTargetView? { + guard ensureInstalled() else { return nil } + let point = hostView.convert(windowPoint, from: nil) + + 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 target = hostedView.paneDropTargetForDrop(at: localPoint) { + return target + } + } + + return nil + } } @MainActor @@ -2097,6 +2116,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 01bd18b6e828..2c8814de0284 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -374,7 +374,9 @@ extension Workspace { let panelTitle = panelTitle(panelId: panelId) let customTitle = panelCustomTitles[panelId] - let directory = panelDirectories[panelId] ?? effectiveRestorableAgent?.workingDirectory + let directory = panelDirectories[panelId] + ?? effectiveRestorableAgent?.workingDirectory + ?? (panel as? TerminalPanel)?.requestedWorkingDirectory let isPinned = pinnedPanelIds.contains(panelId) let isManuallyUnread = manualUnreadPanelIds.contains(panelId) let branchSnapshot = panelGitBranches[panelId].map { @@ -12447,7 +12449,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..574bfe2e7404 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -234,6 +234,7 @@ struct WorkspaceContentView: View { ) PanelContentView( panel: panel, + workspaceId: workspace.id, paneId: paneId, isFocused: isFocused, isSelectedInPane: isSelectedInPane, 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 8bc15782b598..35c2cc9ae659 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( @@ -2518,7 +2522,7 @@ final class FileDropOverlayViewTests: XCTestCase { ) } - func testOverlayDoesNotCaptureFileDragLifecycleWhenPanePreviewDropsAreEnabled() { + func testOverlayDelegatesBrowserFileDragLifecycleToPortalHostedWebView() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), styleMask: [.titled, .closable], @@ -2566,15 +2570,15 @@ final class FileDropOverlayViewTests: XCTestCase { pasteboard: pasteboard ) - XCTAssertEqual(overlay.draggingEntered(dragInfo), []) - XCTAssertFalse(overlay.prepareForDragOperation(dragInfo)) - XCTAssertFalse(overlay.performDragOperation(dragInfo)) + XCTAssertEqual(overlay.draggingEntered(dragInfo), .copy) + XCTAssertTrue(overlay.prepareForDragOperation(dragInfo)) + XCTAssertTrue(overlay.performDragOperation(dragInfo)) overlay.concludeDragOperation(dragInfo) XCTAssertEqual( webView.dragCalls, - [], - "Finder file drops should reach pane-level Bonsplit preview targets instead of the root overlay" + ["entered", "prepare", "perform", "conclude"], + "Finder file drops over browser panes should still reach the portal-hosted WKWebView" ) } } diff --git a/vendor/bonsplit b/vendor/bonsplit index f9b761863467..aa4f69723ea1 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit f9b7618634677618c225120d5dd4d574ffaec2d3 +Subproject commit aa4f69723ea1a5a59df9dc0c9e7bbdfb1f96abc1 From 7763691d01b8f4d369bf48c93b3fa4e9effe9efe Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 5 May 2026 17:11:22 -0700 Subject: [PATCH 2/4] Split shared file drop support --- GhosttyTabs.xcodeproj/project.pbxproj | 12 + Sources/ContentView.swift | 135 +---------- Sources/DragOverlayRoutingPolicy.swift | 128 +++++++++++ Sources/Panels/FilePreviewPanel.swift | 198 ---------------- Sources/Panels/FilePreviewTextEditor.swift | 200 ++++++++++++++++ Sources/TerminalWindowPortal.swift | 57 ++--- Sources/WorkspaceContentView.swift | 1 - cmuxTests/FileDropOverlayViewTests.swift | 256 +++++++++++++++++++++ cmuxTests/WindowAndDragTests.swift | 247 -------------------- 9 files changed, 612 insertions(+), 622 deletions(-) create mode 100644 Sources/DragOverlayRoutingPolicy.swift create mode 100644 Sources/Panels/FilePreviewTextEditor.swift create mode 100644 cmuxTests/FileDropOverlayViewTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b0ea94f561da..3d7b3174155a 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -57,6 +57,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 */; }; @@ -420,6 +423,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 = ""; }; @@ -527,6 +531,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 = ""; }; @@ -917,6 +923,7 @@ A50019B1 /* SettingsSearchAliases.swift */, A5001012 /* ContentView.swift */, C0DE35010000000000000002 /* SidebarScrim.swift */, + D0B10015A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift */, C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */, C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */, D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */, @@ -1031,6 +1038,7 @@ A5001418 /* MarkdownPanel.swift */, A5001419 /* MarkdownPanelView.swift */, A5001423 /* FilePreviewPanel.swift */, + D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */, A5001442A5001442A5001442 /* FilePreviewModeSupport.swift */, A5001444A5001444A5001444 /* FilePreviewWorkspaceOpenSupport.swift */, A5001446A5001446A5001446 /* FilePreviewMagnifyingPDFView.swift */, @@ -1177,6 +1185,7 @@ 1A1B2C3D4E5F607180000004 /* WorkspacePromptSubmitTests.swift */, C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */, BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, + D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */, 2907A0042907A0042907A004 /* AppDelegateIssue2907RoutingTests.swift */, A5001435A5001435A5001435 /* FilePreviewPDFThumbnailSidebarTests.swift */, C0DE31390000000000000104 /* FilePreviewReviewFeedbackTests.swift */, @@ -1527,6 +1536,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 */, @@ -1577,6 +1587,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 */, @@ -1760,6 +1771,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 becf0d59b70f..71122f323ae0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7,139 +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 { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(.fileURL) - } - - static func fileURLs(from pasteboard: NSPasteboard) -> [URL] { - let objects = pasteboard.readObjects( - forClasses: [NSURL.self], - options: [.urlReadingFileURLsOnly: true] - ) ?? [] - return objects.compactMap { object -> URL? in - if let url = object as? URL { - return url.isFileURL ? url : nil - } - if let url = object as? NSURL { - let bridged = url as URL - return bridged.isFileURL ? bridged : nil - } - return nil - } - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - hasLocalDraggingSource: Bool - ) -> Bool { - // The window overlay is the stable AppKit drag destination for Finder - // and sidebar file drags. It delegates file drops to pane-level - // Bonsplit targets so split affordances and preview routing stay local - // to the hovered pane. - _ = 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 { - // 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 @@ -14935,7 +14802,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..e716bf39e45e --- /dev/null +++ b/Sources/DragOverlayRoutingPolicy.swift @@ -0,0 +1,128 @@ +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 { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(.fileURL) + } + + static func fileURLs(from pasteboard: NSPasteboard) -> [URL] { + let objects = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) ?? [] + return objects.compactMap { object -> URL? in + if let url = object as? URL { + return url.isFileURL ? url : nil + } + if let url = object as? NSURL { + let bridged = url as URL + return bridged.isFileURL ? bridged : nil + } + return nil + } + } + + 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/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 1a2909f9288f..c9308c964609 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -925,204 +925,6 @@ struct FilePreviewPanelView: View { } } -private 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 - } - - 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..dc3bef8c46d0 --- /dev/null +++ b/Sources/Panels/FilePreviewTextEditor.swift @@ -0,0 +1,200 @@ +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 + } + + 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 + } +} diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 9c8cdea247ab..c0ba5066e47e 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1726,61 +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) - - 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 viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? { + guard let hit = hostedScrollViewAtWindowPoint(windowPoint) else { return nil } + return hit.view.hitTest(hit.point) ?? hit.view + } - return nil + func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + guard let hit = hostedScrollViewAtWindowPoint(windowPoint) else { return nil } + return hit.view.terminalViewForDrop(at: hit.point) } func terminalPaneDropTargetAtWindowPoint(_ windowPoint: NSPoint) -> TerminalPaneDropTargetView? { - guard ensureInstalled() else { return nil } - let point = hostView.convert(windowPoint, from: nil) - - 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 target = hostedView.paneDropTargetForDrop(at: localPoint) { - return target - } - } - - return nil + guard let hit = hostedScrollViewAtWindowPoint(windowPoint) else { return nil } + return hit.view.paneDropTargetForDrop(at: hit.point) } } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 574bfe2e7404..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 _ = { diff --git a/cmuxTests/FileDropOverlayViewTests.swift b/cmuxTests/FileDropOverlayViewTests.swift new file mode 100644 index 000000000000..ac1679018802 --- /dev/null +++ b/cmuxTests/FileDropOverlayViewTests.swift @@ -0,0 +1,256 @@ +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) + + 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/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 35c2cc9ae659..9063197cee94 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -2337,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 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" - ) - } -} - - @MainActor final class MarkdownPanelPointerObserverViewTests: XCTestCase { private func makeWindow() -> NSWindow { From 2918c828ef8ac2e73f4a97a914bb5e85e0c4fcd1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 5 May 2026 17:40:57 -0700 Subject: [PATCH 3/4] Address file drop review feedback --- Sources/ContentView.swift | 16 ++++++++++------ Sources/Panels/FilePreviewTextEditor.swift | 4 ++++ Sources/Panels/PanelContentView.swift | 1 + cmuxTests/FileDropOverlayViewTests.swift | 1 + cmuxTests/FinderFileDropRegressionTests.swift | 14 +++++++------- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 43c1de072e9e..f8776cec406b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -242,8 +242,9 @@ final class FileDropOverlayView: NSView { } preparedDragWebView = nil if let paneDropTarget { - preparedPaneDropTarget = paneDropTarget - return true + let accepted = paneDropTarget.prepareForDragOperation(sender) + preparedPaneDropTarget = accepted ? paneDropTarget : nil + return accepted } preparedPaneDropTarget = nil return hasPaneTarget @@ -284,9 +285,12 @@ final class FileDropOverlayView: NSView { } preparedDragWebView = nil if let paneDropTarget { - preparedPaneDropTarget = nil - activePaneDropTarget = nil - return paneDropTarget.performDragOperation(sender) + let handled = paneDropTarget.performDragOperation(sender) + if !handled { + preparedPaneDropTarget = nil + activePaneDropTarget = nil + } + return handled } preparedPaneDropTarget = nil activeDragWebView = nil @@ -311,7 +315,7 @@ final class FileDropOverlayView: NSView { let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) webView?.concludeDragOperation(sender) if let paneDropTarget = preparedPaneDropTarget ?? activePaneDropTarget { - paneDropTarget.draggingExited(sender) + paneDropTarget.concludeDragOperation(sender) } } diff --git a/Sources/Panels/FilePreviewTextEditor.swift b/Sources/Panels/FilePreviewTextEditor.swift index dc3bef8c46d0..ef198b413a93 100644 --- a/Sources/Panels/FilePreviewTextEditor.swift +++ b/Sources/Panels/FilePreviewTextEditor.swift @@ -87,6 +87,8 @@ struct FilePreviewTextEditor: NSViewRepresentable { self.panel = panel } + deinit {} + func textDidChange(_ notification: Notification) { guard !isApplyingPanelUpdate, let textView = notification.object as? NSTextView else { return } @@ -121,6 +123,8 @@ final class SavingTextView: NSTextView { private var previewFontSize: CGFloat = 13 private var pendingSaveShortcutChordPrefix: ShortcutStroke? + deinit {} + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() applyFilePreviewTextEditorInsets() diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 648179c1b0f0..92c558d32556 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -90,6 +90,7 @@ struct PanelContentView: View { } private var shouldInstallPaneDropTarget: Bool { + guard isVisibleInUI else { return false } switch panel.panelType { case .markdown, .filePreview: return true diff --git a/cmuxTests/FileDropOverlayViewTests.swift b/cmuxTests/FileDropOverlayViewTests.swift index ac1679018802..168749eea0c1 100644 --- a/cmuxTests/FileDropOverlayViewTests.swift +++ b/cmuxTests/FileDropOverlayViewTests.swift @@ -179,6 +179,7 @@ final class FileDropOverlayViewTests: XCTestCase { 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] 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" ) } From a725315b79e1c8cf630295cd3c4b49dee13f1747 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 5 May 2026 17:59:35 -0700 Subject: [PATCH 4/4] Clear stale pane drop state --- Sources/ContentView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f8776cec406b..f698c533aec4 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -234,6 +234,8 @@ final class FileDropOverlayView: NSView { #endif guard shouldCapture else { preparedDragWebView = nil + preparedPaneDropTarget = nil + activePaneDropTarget = nil return false } if let webView { @@ -277,6 +279,8 @@ final class FileDropOverlayView: NSView { guard shouldCapture else { preparedDragWebView = nil activeDragWebView = nil + preparedPaneDropTarget = nil + activePaneDropTarget = nil return false } if let webView {