Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ extension TabDragSession {

func paneController(stripID: UUID) -> PaneController? {
for controller in services.windows.controllers {
for pane in controller.content?.panes.values.map({ $0 }) ?? [] where pane.stripModel.stripID == stripID { return pane }
for pane in controller.onScreenContents.flatMap(\.panes.values) where pane.stripModel.stripID == stripID { return pane }
}
return nil
}
Expand All @@ -247,7 +247,7 @@ extension TabDragSession {
/// new one follows) and that column's daemon id.
/// Any pane of `screenID` in `window` (a dock opens on the anchor's screen).
func screenAnchor(screenID: String, in window: WindowController?) -> PaneModel? {
guard let content = window?.content,
guard let content = window?.onScreenContent(holdingScreen: screenID),
let screen = content.layoutModel.screens.first(where: { $0.id.rawValue == screenID }),
let anchor = screen.layout.panes.first, let handle = content.handles.panes[anchor] else { return nil }
return content.daemon.store.pane(handle)
Expand All @@ -256,7 +256,7 @@ extension TabDragSession {
/// `avoiding`: the pane the drag empties. Its only tab cannot leave through it (the daemon refuses that as
/// a split, as DockColumnHandlers notes), so another pane anchors the new column; `after` still places it.
func columnAnchor(screenID: String, after: String, in window: WindowController?, avoiding: String? = nil) -> (PaneModel, DaemonColumnID?)? {
guard let content = window?.content,
guard let content = window?.onScreenContent(holdingScreen: screenID),
let screen = content.layoutModel.screens.first(where: { $0.id.rawValue == screenID }),
let column = screen.layout.columns.first(where: { $0.id.rawValue == after }),
let anchor = column.root.panes.last(where: { $0.rawValue != avoiding }) ?? screen.layout.panes.first(where: { $0.rawValue != avoiding }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ extension TabDragSession {
/// Auto-scrolls the columns screen under the pointer. Strips and the
/// sidebar auto-scroll themselves while they hold a drag.
func autoscroll(_ drag: Drag, dt: Double) -> Bool {
guard let controller = window(at: drag.point), let layout = controller.content?.layoutView, let window = layout.window else {
guard let controller = window(at: drag.point), let layout = controller.content(atScreen: drag.point)?.layoutView, let window = layout.window else {
return false
}
return layout.autoscrollTabDrag(locationInWindow: window.convertPoint(fromScreen: drag.point), dt: dt)
Expand All @@ -73,7 +73,7 @@ extension TabDragSession {
removeMonitors(drag)
if case .workspaces = drag.source.item { return finishWorkspaces(drag, commit: commit) }
let outcome = commit ? drag.outcome : .cancel
if commit { reportRefusal(drag) }
if commit { noteCommit(drag) }
focusDragEnded(drag, outcome: outcome)
let winner = drag.winner
for provider in drag.touched.values where provider !== winner?.provider || outcome == .cancel {
Expand Down Expand Up @@ -112,11 +112,11 @@ extension TabDragSession {
}
}

/// A drop where nothing answered inside a window is a resolver defect
/// and says so instead of springing back silently (tab-dnd). A refused
/// zone springs back without a word (Lawrence 2026-10-05).
func reportRefusal(_ drag: Drag) {
guard case .none = drag.resolution.preview else { return }
/// A drop where nothing answered inside a window is a resolver defect and says so instead of springing back
/// silently (tab-dnd); a refused zone springs back without a word (Lawrence 2026-10-05). A drop into a workspace
/// shown beside the focused one (`sidebar.multiView`) focuses it first, unless Option files the tabs away.
func noteCommit(_ drag: Drag) {
guard case .none = drag.resolution.preview else { return drag.filesAway ? () : drag.winner?.window?.focusDropTarget(drag.outcome, atScreen: drag.point) ?? () }
services.registry.refuse(drag.noTargetReason ?? TabDropStrings.noTarget)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ enum WorkspaceMerge {
guard let source = services.workspace(id: workspaceID), let outcome = outcome(kind) else { return }
let tabs = source.screens.flatMap(\.panes).flatMap(\.tabs)
guard let first = tabs.first, let daemon = services.machines.daemon(forWorkspace: workspaceID) else { return }
// Into a workspace shown beside the focused one (`sidebar.multiView`): it is focused first, as a press does.
if !drag.filesAway { window.focusDropTarget(outcome, atScreen: drag.point) }
session.claimClosing(source)
let transaction = ClientTransactionID.generate()
session.executeTab(outcome, tab: first, dropWindow: window, drag: drag, transaction: transaction) { [weak session] ok in
Expand All @@ -36,17 +38,17 @@ enum WorkspaceMerge {
/// drag that may merge there, and the layout adapter showing it.
static func hit(_ point: CGPoint, ids: [String], group: String?, window: WindowController,
layout: LayoutTabDropTarget, services: AppServices) -> TabDropProposal? {
guard let id = ids.first, allowed(ids, group: group, into: window, services: services),
guard let id = ids.first, allowed(ids, group: group, into: window.content(atScreen: point)?.workspace, services: services),
let proposal = layout.workspaceHitTest(screenPoint: point, workspaceID: id), outcome(proposal.kind) != nil else { return nil }
return proposal
}

/// Whether `ids` may merge into `window`'s shown workspace: one
/// Whether `ids` may merge into `shown`, a workspace on screen: one
/// workspace (any number of tabs, even one), not a fixed one, not the
/// shown one, on the same machine.
static func allowed(_ ids: [String], group: String?, into window: WindowController, services: AppServices) -> Bool {
static func allowed(_ ids: [String], group: String?, into shown: WorkspaceModel?, services: AppServices) -> Bool {
guard group == nil, ids.count == 1, let id = ids.first, let source = services.workspace(id: id),
!staysPut(kind: source.kind), let shown = window.content?.workspace, shown.id != id else { return false }
!staysPut(kind: source.kind), let shown, shown.id != id else { return false }
return services.machines.daemon(forWorkspace: id) === services.machines.daemon(forWorkspace: shown.id)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ extension TabDragSession {
}
if case .window(_, .window) = drag.workspaceOutcome, highlight == nil {
highlight = controller?.window?.frame
} else if case .window(_, .beside(let edge)) = drag.workspaceOutcome { highlight = controller?.besideHighlight(edge) ?? highlight }
} else if case .window(_, .beside(let edge)) = drag.workspaceOutcome { highlight = controller?.besideHighlight(edge, at: point) ?? highlight }
drag.workspaceHighlight = drag.workspaceOutcome == .cancel ? nil : highlight
present(drag)
wake(drag)
Expand Down Expand Up @@ -117,7 +117,7 @@ extension TabDragSession {
case let .intoGroup(group): controller.sidebar.accept(workspaceIDs, intoGroup: group)
case .window: controller.sidebar.accept(workspaceIDs, at: nil)
case let .merge(kind): ids.first.map { WorkspaceMerge.run($0, at: kind, into: controller, drag: drag, session: self) }
case let .beside(edge): ids.first.map { controller.placeBeside($0, edge: edge) }
case let .beside(edge): ids.first.map { controller.placeBeside($0, edge: edge, at: drag.point) }
}
services.windows.bringToFront(controller)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ final class TabDragSession: NSObject {
func providers(in controller: WindowController, near point: CGPoint, drag: Drag) -> [any TabDropTargetProviding] {
let adapters = self.adapters(for: controller, drag: drag)
var list: [any TabDropTargetProviding] = [adapters.sidebar]
for pane in controller.content?.panes.values.map({ $0 }) ?? [] {
for pane in controller.onScreenContents.flatMap(\.panes.values) {
let strip = pane.view.stripView
guard let window = strip.window, !strip.isHiddenOrHasHiddenAncestor else { continue }
let frame = window.convertToScreen(strip.convert(strip.bounds, to: nil)).insetBy(dx: 0, dy: -Metrics.space4)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ final class SidebarTabDropTarget: TabDropTargetProviding {

/// The workspace layout: pane edge zones (new split), pane centers (join
/// that pane's strip at the end), and column gaps (new strip column). Reads
/// the window's current content on every call, so a spring-loaded
/// workspace switch mid-drag is picked up. It answers for every point of
/// the workspace on screen under the point on every call (the focused one,
/// or one shown beside it), so a spring-loaded workspace switch mid-drag is
/// picked up. It answers for every point of
/// its window that no surface before it took (the title bar, the window
/// edges): the point is moved to the nearest point of the layout, so a
/// drop anywhere in a window previews and lands somewhere (tab-dnd).
Expand All @@ -122,14 +123,12 @@ final class LayoutTabDropTarget: TabDropTargetProviding {
self.window = window
}

var layoutView: LayoutRootView? { window?.content?.layoutView }

/// When the pane edge under a resting pointer arms its split; nil when none is pending.
var dwellDeadline: CFTimeInterval? { touched?.tabDragDwellDeadline }

func dropHitTest(screenPoint: CGPoint, payload: TabDragPayload) -> TabDropProposal? {
// A terminal tab's own pane: the facing edge of the pane beside it joins that pane (bonsplit, #22287).
let source = window?.content?.panes.values.first { $0.pane.tabs.contains { $0.id == payload.dragID && $0.kind == .pty } }
let source = window?.content(atScreen: screenPoint)?.panes.values.first { $0.pane.tabs.contains { $0.id == payload.dragID && $0.kind == .pty } }
return proposal(screenPoint: screenPoint, dragKey: payload.dragID, joinsFrom: source?.layoutPaneID) {
// A drop on the tabs' own pane body leaves them where they are (bonsplit, #22287).
payload.ownPlace(in: $0.stripModel.orderedTabs)
Expand All @@ -141,7 +140,7 @@ final class LayoutTabDropTarget: TabDropTargetProviding {
/// rest of the window keeps the workspace's own drops). Its tabs join
/// no tab group.
func workspaceHitTest(screenPoint: CGPoint, workspaceID: String) -> TabDropProposal? {
guard let layout = layoutView, let nsWindow = layout.window,
guard let layout = window?.content(atScreen: screenPoint)?.layoutView, let nsWindow = layout.window,
layout.bounds.contains(layout.convert(nsWindow.convertPoint(fromScreen: screenPoint), from: nil)) else { return nil }
return proposal(screenPoint: screenPoint, dragKey: workspaceID) { _ in nil }
}
Expand All @@ -151,7 +150,8 @@ final class LayoutTabDropTarget: TabDropTargetProviding {
/// `controller` when they are in it: a center drop there keeps them.
private func proposal(screenPoint: CGPoint, dragKey: String, joinsFrom: LayoutPaneID? = nil,
ownPlace: (PaneController) -> (index: Int, group: String?)?) -> TabDropProposal? {
guard let content = window?.content, case let layout = content.layoutView, let nsWindow = layout.window,
// The workspace under the point: with `sidebar.multiView`, one shown beside the focused one takes its drops.
guard let content = window?.content(atScreen: screenPoint), case let layout = content.layoutView, let nsWindow = layout.window,
!layout.bounds.isEmpty else { return nil }
let local = layout.convert(nsWindow.convertPoint(fromScreen: screenPoint), from: nil)
let inside = layout.bounds.insetBy(dx: 0.5, dy: 0.5)
Expand Down Expand Up @@ -184,7 +184,7 @@ final class LayoutTabDropTarget: TabDropTargetProviding {
/// Outlines a strip's insert slot (`screenRect`) in this layout's
/// overlay, so one outline moves between pane zones and strip slots.
func outline(screenRect: CGRect) {
guard let layout = layoutView else { return }
guard let layout = window?.content(atScreen: CGPoint(x: screenRect.midX, y: screenRect.midY))?.layoutView else { return }
if touched !== layout { touched?.cancelTabDrag() }
touched = layout
layout.showTabDragOutline(screenRect: screenRect)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ extension WindowController {
setOnScreen(Set(views.keys))
}

/// A sidebar workspace dropped on `edge` of a pane of the shown workspace (`WorkspaceDropTarget.beside`): it shows
/// on that side of the shown one, which keeps the focus; one from another window moves into this one first.
func placeBeside(_ id: String, edge: TabDropEdge) {
guard let shown = content?.workspace.id, let set = (state.viewSet ?? .workspace(shown)).placing(id, beside: shown, edge: edge) else { return }
/// A sidebar workspace dropped on `edge` of a pane of a shown workspace (`WorkspaceDropTarget.beside`), the one
/// on screen under `screenPoint`: it shows on that side of it, and the focused one keeps the focus; one from
/// another window moves into this one first.
func placeBeside(_ id: String, edge: TabDropEdge, at screenPoint: CGPoint) {
guard let shown = content?.workspace.id, let target = content(atScreen: screenPoint)?.workspace.id,
let set = (state.viewSet ?? .workspace(shown)).placing(id, beside: target, edge: edge) else { return }
services.windows.claim(workspaceID: id, in: state, select: false)
// An incognito and a normal window never share a workspace: the claim refused it.
guard services.windows.registry.members(of: state.id).contains(id) else { return }
Expand All @@ -81,10 +83,10 @@ extension WindowController {
services.windows.recordSaver.stateDidChange(state)
}

/// The half of the shown workspace a workspace dropped on `edge` takes (screen coordinates), where the drag's
/// ghost lands.
func besideHighlight(_ edge: TabDropEdge) -> CGRect? {
guard let view = content?.contentView, let window = view.window else { return nil }
/// The half of the shown workspace under `screenPoint` a workspace dropped on `edge` takes (screen coordinates),
/// where the drag's ghost lands.
func besideHighlight(_ edge: TabDropEdge, at screenPoint: CGPoint) -> CGRect? {
guard let view = content(atScreen: screenPoint)?.contentView, let window = view.window else { return nil }
return WorkspaceViewSet.half(of: window.convertToScreen(view.convert(view.bounds, to: nil)), at: edge, yDown: false)
}

Expand All @@ -100,6 +102,42 @@ extension WindowController {
}
}

/// The workspaces on screen: the focused one, then the ones shown beside it.
var onScreenContents: [WorkspaceContentController] {
guard let content else { return [] }
guard let set = state.viewSet else { return [content] }
return [content] + set.ids.compactMap { id in
parked[id].flatMap { $0.contentView.superview === root.contentHost && !$0.contentView.isHidden ? $0 : nil }
}
}

/// The workspace on screen whose layout has screen `screenID` (a drop's column or dock anchor).
func onScreenContent(holdingScreen screenID: String) -> WorkspaceContentController? {
onScreenContents.first { $0.layoutModel.screens.contains { $0.id.rawValue == screenID } }
}

/// The workspace a drop at `screenPoint` goes to: one shown beside the focused one when the point is in its area,
/// else the focused one (the drag clamps a point off every workspace into its layout).
func content(atScreen screenPoint: CGPoint) -> WorkspaceContentController? {
guard let window else { return content }
let point = window.convertPoint(fromScreen: screenPoint)
return onScreenContents.dropFirst().first { $0.contentView.bounds.contains($0.contentView.convert(point, from: nil)) } ?? content
}

/// A drop into a workspace shown beside the focused one focuses it first, as a press there does, so the drop lands
/// in the window's focused content: a strip's workspace for a strip drop, the one under the point for a pane
/// target. Other outcomes (the sidebar, a new window) leave the focus.
func focusDropTarget(_ outcome: TabDragOutcome, atScreen screenPoint: CGPoint) {
let target: WorkspaceContentController?
switch outcome {
case let .strip(stripID, _, _): target = onScreenContents.first { $0.panes.values.contains { $0.stripModel.stripID == stripID } }
case .newSplit, .newColumn, .newDock: target = content(atScreen: screenPoint)
default: return
}
guard let target, target !== content else { return }
services.windows.show(workspaceID: target.workspace.id, in: state)
}

/// The parked content of workspace `id`, mounted now when the window has none (a set restored with the window).
private func companion(_ id: String) -> WorkspaceContentController? {
guard let (workspace, daemon) = services.machines.workspace(id: id) else { return nil }
Expand Down
Loading