From 6748c202f2d2e65ce397bded749080803e54cb6a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:20:22 -0800 Subject: [PATCH] Fix sidebar drag-and-drop broken by FileDropOverlayView (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix sidebar drag-and-drop broken by FileDropOverlayView The FileDropOverlayView (added in 9fd3cc2) sits on the window's theme frame above the content view. Its hitTest returned self for all events, causing AppKit to route drag sessions to the overlay instead of the content view where SwiftUI lives. AppKit walks UP the superview chain from the hit-tested view, never checking siblings — so SwiftUI's .onDrop handlers for sidebar tab reordering were never reached. Three changes fix this: 1. Smart hitTest: check NSPasteboard(name: .drag) for .fileURL and only return self during Finder file drags. Return nil otherwise so mouse events and internal drags pass through to the content view. 2. Custom UTType for sidebar drags: replace the fragile UTType.plainText hack with a proper com.cmux.sidebar-tab-reorder type registered in Info.plist. Uses visibility: .ownProcess since it's internal-only. 3. Narrow overlay registration: only register for .fileURL instead of .fileURL + .URL + .string. The broad .string type collided with text-based drag payloads. * Add custom UTType Info.plist pitfall to CLAUDE.md --- CLAUDE.md | 1 + Resources/Info.plist | 10 ++++++++ Sources/ContentView.swift | 48 +++++++++++++++++++++++++++++++-------- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d168661f..2d989dd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,7 @@ tail -f /tmp/cmux-debug.log ## Pitfalls +- **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`). - Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag. - **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd && git merge-base --is-ancestor HEAD origin/main`. diff --git a/Resources/Info.plist b/Resources/Info.plist index 4a293313..da978c67 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -81,6 +81,16 @@ public.data + + UTTypeIdentifier + com.cmux.sidebar-tab-reorder + UTTypeDescription + cmux Sidebar Tab Reorder + UTTypeConformsTo + + public.data + + SUFeedURL https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 764fe0b9..d7682952 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -176,12 +176,26 @@ final class FileDropOverlayView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) - registerForDraggedTypes([.fileURL, .URL, .string]) + registerForDraggedTypes([.fileURL]) } required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } - // MARK: Mouse forwarding – hide self so the event reaches views below. + // MARK: Hit-testing — only participate when the system drag pasteboard contains file + // URLs (i.e. a Finder file drag is in progress). For everything else — mouse events, + // sidebar tab reorder, bonsplit tab drags — return nil so events route to the content + // view below and SwiftUI / bonsplit drag-and-drop works normally. + + override func hitTest(_ point: NSPoint) -> NSView? { + let pb = NSPasteboard(name: .drag) + if let types = pb.types, types.contains(.fileURL) { + return super.hitTest(point) + } + return nil + } + + // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard + // data causes hitTest to return self when no drag is actually active. private func forwardEvent(_ event: NSEvent) { isHidden = true @@ -200,7 +214,7 @@ final class FileDropOverlayView: NSView { override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } override func scrollWheel(with event: NSEvent) { forwardEvent(event) } - // MARK: NSDraggingDestination – only accept drops over terminal views. + // MARK: NSDraggingDestination – only accept file drops over terminal views. override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { return dragOperationForSender(sender) @@ -217,7 +231,7 @@ final class FileDropOverlayView: NSView { private func dragOperationForSender(_ sender: any NSDraggingInfo) -> NSDragOperation { guard let types = sender.draggingPasteboard.types, - types.contains(where: { $0 == .fileURL || $0 == .URL || $0 == .string }), + types.contains(.fileURL), terminalUnderPoint(sender.draggingLocation) != nil else { return [] } @@ -229,9 +243,6 @@ final class FileDropOverlayView: NSView { guard let window, let contentView = window.contentView, let themeFrame = contentView.superview else { return nil } isHidden = true - // hitTest expects the point in the receiver's superview's coordinate system. - // Converting to contentView's own coords would flip y (NSHostingView is flipped) - // causing top/bottom split targeting to be inverted. let point = themeFrame.convert(windowPoint, from: nil) let hitView = contentView.hitTest(point) isHidden = false @@ -1314,6 +1325,9 @@ private struct TabItemView: View { } } .onDrag { + #if DEBUG + dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") + #endif draggedTabId = tab.id dropIndicator = nil return SidebarTabDragPayload.provider(for: tab.id) @@ -2007,11 +2021,17 @@ private final class SidebarDragAutoScrollController: ObservableObject { } private enum SidebarTabDragPayload { - static let typeIdentifier = UTType.plainText.identifier + static let typeIdentifier = "com.cmux.sidebar-tab-reorder" private static let prefix = "cmux.sidebar-tab." static func provider(for tabId: UUID) -> NSItemProvider { - NSItemProvider(object: "\(prefix)\(tabId.uuidString)" as NSString) + let provider = NSItemProvider() + let payload = "\(prefix)\(tabId.uuidString)" + provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in + completion(payload.data(using: .utf8), nil) + return nil + } + return provider } } @@ -2026,10 +2046,18 @@ private struct SidebarTabDropDelegate: DropDelegate { @Binding var dropIndicator: SidebarDropIndicator? func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil + let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let hasDrag = draggedTabId != nil + #if DEBUG + dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") + #endif + return hasType && hasDrag } func dropEntered(info: DropInfo) { + #if DEBUG + dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif dragAutoScrollController.updateFromDragLocation() updateDropIndicator(for: info) }