Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
19255c6
Support external file drops
lawrencecchen Apr 24, 2026
a6ba1db
Merge commit '427e48a9166ed65a42f0f618b3c84ac47fba1b04' into task-fil…
lawrencecchen Apr 27, 2026
0f608a4
Merge remote-tracking branch 'origin/main' into task-file-preview-fil…
lawrencecchen Apr 28, 2026
be79a65
Harden external file drop validation
lawrencecchen Apr 28, 2026
68ec77f
Merge commit 'd403ae6aa63a0ffb6f7c05d76ed85a874b2ee18b' into task-fil…
lawrencecchen Apr 28, 2026
c1e7c7f
Merge commit 'a3a4c3c' into task-file-preview-file-drops
lawrencecchen Apr 30, 2026
e73e43f
Merge commit 'a1fb2111f5e6f1babdd40357ad6c0e7f61834fc7' into task-fil…
lawrencecchen Apr 30, 2026
cbd73c4
Merge commit 'cb4910e5d9602f97aa717e0790e6b9a4a3530650' into task-fil…
lawrencecchen Apr 30, 2026
4f72a3e
Merge commit '84e5bd57d979feea8f515063dfb1b0edf9b28344' into task-fil…
lawrencecchen Apr 30, 2026
d0de5d1
Merge remote-tracking branch 'origin/main' into task-file-preview-fil…
lawrencecchen Apr 30, 2026
625c370
Add split button tab indicator regression test
lawrencecchen Apr 30, 2026
e022718
Unify tab bar button lane layout
lawrencecchen Apr 30, 2026
77a6f8c
Preserve split action button horizontal sizing
lawrencecchen Apr 30, 2026
f462bf5
Make tab bar fade full height
lawrencecchen Apr 30, 2026
e625083
Share tab bar chrome fade mask
lawrencecchen Apr 30, 2026
7aef42c
Cap split action lane width
lawrencecchen May 1, 2026
bfaad67
Extend split action backdrop and scroll fades
lawrencecchen May 1, 2026
4730ebb
Extend solid action backdrop over content fade
lawrencecchen May 1, 2026
e48b4ae
Move split action backdrop over tab chrome
lawrencecchen May 1, 2026
9fffbdd
Add shared backdrop action lane regression test
lawrencecchen May 1, 2026
d212b31
Route tab bar action chrome through snapshot
lawrencecchen May 1, 2026
a9ea9bd
Respect transparent shared backdrop action lanes
lawrencecchen May 1, 2026
5dd5b92
Honor split action content occlusion fraction
lawrencecchen May 1, 2026
3db51a7
Merge remote-tracking branch 'origin/task-file-preview-file-drops' in…
lawrencecchen May 1, 2026
eaae2e9
Add action lane separator regression test
lawrencecchen May 1, 2026
bb53b34
Draw action lane bottom separator
lawrencecchen May 1, 2026
b68e2c4
Share action lane backdrop segment frames
lawrencecchen May 1, 2026
c5b2449
Narrow action lane separator fade ramp
lawrencecchen May 1, 2026
125625a
Match action lane separator to tab mask
lawrencecchen May 1, 2026
7c4439c
Widen action lane solid chrome
lawrencecchen May 1, 2026
8106a1a
Layer action lane border below tab separator
lawrencecchen May 1, 2026
6dac95f
Unify action lane geometry
lawrencecchen May 1, 2026
819688a
Cover overflowing action lane surface
lawrencecchen May 1, 2026
6635168
Add action lane overflow regression coverage
lawrencecchen May 1, 2026
cc0f548
Align action lane masking and separator fade
lawrencecchen May 1, 2026
eeb04dd
Make render samplers alpha-aware
lawrencecchen May 1, 2026
9d082c0
Keep legacy file-drop hover zones valid
lawrencecchen May 1, 2026
90f4981
Keep file drops out of stale tab-drag state
lawrencecchen May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Sources/Bonsplit/Internal/Styling/TabBarColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ enum TabBarColors {
splitButtonBackdropColor(for: appearance) ?? nsColorBarBackground(for: appearance)
}

static func nsColorSplitButtonBackdropOccludingSurface(for appearance: BonsplitConfiguration.Appearance) -> NSColor {
nonClearColor(splitButtonBackdropColor(for: appearance))
?? .clear
}

static func nsColorSplitButtonBackdrop(
for appearance: BonsplitConfiguration.Appearance,
focused: Bool = true
Expand All @@ -145,9 +150,7 @@ enum TabBarColors {
}

static func shouldPaintSplitButtonBackdrop(for appearance: BonsplitConfiguration.Appearance) -> Bool {
splitButtonBackdropColor(for: appearance) != nil
|| tabBarBackgroundColor(for: appearance) != nil
|| chromeBackgroundColor(for: appearance) != nil
nonClearColor(splitButtonBackdropColor(for: appearance)) != nil
}

static var barMaterial: Material {
Expand Down
149 changes: 116 additions & 33 deletions Sources/Bonsplit/Internal/Views/PaneContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ struct PaneContainerView<Content: View, EmptyContent: View>: View {
.allowsHitTesting(!isTabDragActive)

Color.clear
.onDrop(of: [.tabTransfer], delegate: UnifiedPaneDropDelegate(
.onDrop(of: [.tabTransfer, .fileURL], delegate: UnifiedPaneDropDelegate(
size: size,
pane: pane,
controller: controller,
Expand Down Expand Up @@ -408,29 +408,28 @@ struct UnifiedPaneDropDelegate: DropDelegate {
// may not have propagated yet when performDrop runs.
guard let draggedTab = controller.activeDragTab ?? controller.draggingTab,
let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate tab-drop path on tabTransfer payload

Now that this drop target accepts .fileURL, performDrop still prioritizes the in-memory tab-drag state (activeDragTab/draggingTab) before checking the dropped item types, so any lingering local drag state causes a file drop to be handled as a tab move/split instead of a file drop. This is a behavioral regression introduced by adding file support: a stale tab-drag state can make Finder file drops unexpectedly rearrange tabs rather than invoking file handlers.

Useful? React with 👍 / 👎.

guard let transfer = decodeTransfer(from: info),
transfer.isFromCurrentProcess else {
return false
}
let destination: BonsplitController.ExternalTabDropRequest.Destination
if zone == .center {
destination = .insert(targetPane: pane.id, targetIndex: nil)
} else if let orientation = zone.orientation {
destination = .split(
targetPane: pane.id,
orientation: orientation,
insertFirst: zone.insertsFirst
if info.hasItemsConforming(to: [.tabTransfer]) {
guard let transfer = decodeTransfer(from: info),
transfer.isFromCurrentProcess,
let destination = destination(for: zone) else {
return false
}

let request = BonsplitController.ExternalTabDropRequest(
tabId: TabID(id: transfer.tab.id),
sourcePaneId: PaneID(id: transfer.sourcePaneId),
destination: destination
)
} else {
return false
let handled = bonsplitController.onExternalTabDrop?(request) ?? false
if handled {
dropLifecycle = .idle
activeDropZone = nil
}
return handled
}

let request = BonsplitController.ExternalTabDropRequest(
tabId: TabID(id: transfer.tab.id),
sourcePaneId: PaneID(id: transfer.sourcePaneId),
destination: destination
)
let handled = bonsplitController.onExternalTabDrop?(request) ?? false
guard info.hasItemsConforming(to: [.fileURL]) else { return false }
let handled = performFileDrop(info: info, zone: zone)
if handled {
dropLifecycle = .idle
activeDropZone = nil
Expand Down Expand Up @@ -509,14 +508,14 @@ struct UnifiedPaneDropDelegate: DropDelegate {
#if DEBUG
dlog("pane.dropUpdated.skip pane=\(pane.id.id.uuidString.prefix(5)) reason=lifecycle_idle")
#endif
return DropProposal(operation: .move)
return DropProposal(operation: dropOperation(for: info))
}
let zone = effectiveZone(for: info)
activeDropZone = zone
#if DEBUG
dlog("pane.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) zone=\(zone)")
#endif
return DropProposal(operation: .move)
return DropProposal(operation: dropOperation(for: info))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revalidate file-drop zones on drag updates

For hosts that only set the legacy onFileDrop callback, validateDrop accepts files only in the center zone, but dropUpdated still switches activeDropZone to edge split zones and keeps returning a .copy proposal. If the drag enters through center and then moves to an edge, the UI advertises a valid edge drop even though performFileDrop later rejects it (destination is .split and legacy handling only accepts .insert), so drops can fail unexpectedly at release time. Rechecking acceptsFileDrop in dropUpdated (or constraining file zones there) would keep feedback and actual drop handling consistent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d082c0 by making dropUpdated use the same file-drop zone acceptance as validateDrop.

— Claude Code

}

func validateDrop(info: DropInfo) -> Bool {
Expand All @@ -527,33 +526,117 @@ struct UnifiedPaneDropDelegate: DropDelegate {
#endif
return false
}
// The custom UTType alone is sufficient — only Bonsplit tab drags produce it.
// Do NOT gate on draggingTab != nil: @Observable changes from createItemProvider
// may not have propagated to the drop delegate yet, causing false rejections.
let hasType = info.hasItemsConforming(to: [.tabTransfer])
guard hasType else { return false }
let hasTabTransfer = info.hasItemsConforming(to: [.tabTransfer])
let hasFileURL = info.hasItemsConforming(to: [.fileURL])
guard hasTabTransfer || hasFileURL else { return false }

// Local drags use in-memory state and are always same-process.
if controller.activeDragTab != nil || controller.draggingTab != nil {
return true
}

// External drags (another Bonsplit controller) must include a payload from this process.
guard let transfer = decodeTransfer(from: info),
transfer.isFromCurrentProcess else {
return false
if hasTabTransfer {
// External drags (another Bonsplit controller) must include a payload from this process.
guard let transfer = decodeTransfer(from: info),
transfer.isFromCurrentProcess else {
return false
}
} else if hasFileURL {
guard Self.acceptsFileDrop(
zone: effectiveZone(for: info),
hasExternalFileDropHandler: bonsplitController.onExternalFileDrop != nil,
hasLegacyFileDropHandler: controller.onFileDrop != nil
) else {
return false
}
}
#if DEBUG
let hasDrag = controller.draggingTab != nil
let hasActive = controller.activeDragTab != nil
dlog(
"pane.validateDrop pane=\(pane.id.id.uuidString.prefix(5)) " +
"allowed=\(hasType ? 1 : 0) hasDrag=\(hasDrag ? 1 : 0) hasActive=\(hasActive ? 1 : 0)"
"hasTab=\(hasTabTransfer ? 1 : 0) hasFile=\(hasFileURL ? 1 : 0) " +
"hasDrag=\(hasDrag ? 1 : 0) hasActive=\(hasActive ? 1 : 0)"
)
#endif
return true
}

static func acceptsFileDrop(
zone: DropZone,
hasExternalFileDropHandler: Bool,
hasLegacyFileDropHandler: Bool
) -> Bool {
if hasExternalFileDropHandler {
return true
}
guard hasLegacyFileDropHandler else {
return false
}
return zone == .center
}

private func dropOperation(for info: DropInfo) -> DropOperation {
info.hasItemsConforming(to: [.fileURL]) && !info.hasItemsConforming(to: [.tabTransfer])
? .copy
: .move
}

private func destination(
for zone: DropZone
) -> BonsplitController.ExternalTabDropRequest.Destination? {
if zone == .center {
return .insert(targetPane: pane.id, targetIndex: nil)
}
guard let orientation = zone.orientation else { return nil }
return .split(
targetPane: pane.id,
orientation: orientation,
insertFirst: zone.insertsFirst
)
}

private func performFileDrop(info: DropInfo, zone: DropZone) -> Bool {
guard let destination = destination(for: zone) else { return false }
let urls = fileURLs(from: info)
guard !urls.isEmpty else { return false }

if let handler = bonsplitController.onExternalFileDrop {
return handler(BonsplitController.ExternalFileDropRequest(urls: urls, destination: destination))
}

if case .insert = destination {
return controller.onFileDrop?(urls, pane.id) ?? false
}
return false
}

private func fileURLs(from info: DropInfo) -> [URL] {
guard info.hasItemsConforming(to: [.fileURL]) else { return [] }

// DropInfo exposes file URLs through asynchronous item providers, but
// DropDelegate.performDrop needs a synchronous result. AppKit publishes
// Finder/local file drags on the drag pasteboard for this callback.
return Self.fileURLs(from: NSPasteboard(name: .drag))
Comment on lines +671 to +677

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load dropped file URLs from DropInfo providers

performFileDrop calls fileURLs(from:), but that helper never reads DropInfo’s item providers and instead only inspects NSPasteboard(name: .drag). In drag sources that expose public.file-url asynchronously (or via file promises) without immediately materializing NSURL objects on the drag pasteboard, validateDrop can accept the drop while performDrop always sees an empty URL list and rejects it. This creates user-visible false negatives for external file drops even when handlers are configured.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this in this PR because DropDelegate.performDrop and Bonsplit's file-drop handler are synchronous. Provider-only file promises need a separate async drop API instead of blocking the main thread; the supported synchronous path remains the drag pasteboard.

— Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handled in 90f4981. The synchronous DropDelegate path now only validates file drops when the drag pasteboard has readable file URLs, matching what performDrop can actually consume. Provider-only async file promises need a separate async drop API instead of accepting a drop that this delegate cannot fulfill synchronously.

}

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
}
}

private func decodeTransfer(from string: String) -> TabTransferData? {
guard let data = string.data(using: .utf8),
let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) else {
Expand Down
Loading
Loading