fix: keep pane drag alive into workspace sidebar

This commit is contained in:
Kaede Akino committed 2026-08-28 16:19:33 +08:00
1 parent 8d323ab649
commit aae0923e37
2 files changed
+103 -24

No files matched your search

@@ -14,7 +14,12 @@ struct WorkspaceContentView: View {
var body: some View {
ZStack(alignment: .bottom) {
SplitNodeView(node: displayedLayout, workspace: workspace, isActive: isActive)
SplitNodeView(
node: workspace.layout,
workspace: workspace,
isActive: isActive,
omittedPaneID: omittedPaneID
)
.id(workspace.id)
if workspace.commandGeneratorState != .idle {
@@ -82,23 +87,23 @@ struct WorkspaceContentView: View {
#endif
}
/// While one of this workspace's panes is being dragged, the tree is drawn without
/// it.
/// While one of this Workspace's panes is being dragged, its branch collapses
/// without removing the source view from the hierarchy.
///
/// So the arrangement under the pointer is the one you would actually get:
/// the remaining panes have already reflowed into the space the dragged one
/// is leaving, and the drop rectangle lands on that rather than on a layout
/// you are in the middle of abandoning.
///
/// Read-only — `SplitNode.removing` returns a new tree and nothing is
/// committed until the drop. Releasing over nothing restores this by itself,
/// because the drag state clears and the guard stops matching.
private var displayedLayout: SplitNode {
/// Keeping the original `PaneView` mounted is required by AppKit: removing
/// the view that owns `.onDrag` can end the native drag session before the
/// pointer reaches the Navigator. `SplitBranchView` uses this identity only
/// for temporary geometry; the model tree is unchanged until a drop lands.
private var omittedPaneID: Pane.ID? {
guard case .pane(let id)? = window.dragging,
workspace.layout.pane(id) != nil,
let without = workspace.layout.removing(id)
else { return workspace.layout }
return without
workspace.layout.pane(id) != nil
else { return nil }
return id
}
}
@@ -179,6 +184,7 @@ private struct SplitNodeView: View {
let node: SplitNode
let workspace: Workspace
let isActive: Bool
let omittedPaneID: Pane.ID?
var body: some View {
switch node {
@@ -187,8 +193,16 @@ private struct SplitNodeView: View {
// instead of being torn down and rebuilt as the tree reshapes.
PaneView(pane: pane, workspace: workspace, isActive: isActive)
.id(pane.id)
.opacity(omittedPaneID == pane.id ? 0 : 1)
.allowsHitTesting(omittedPaneID != pane.id)
.accessibilityHidden(omittedPaneID == pane.id)
case .split(let branch):
SplitBranchView(branch: branch, workspace: workspace, isActive: isActive)
SplitBranchView(
branch: branch,
workspace: workspace,
isActive: isActive,
omittedPaneID: omittedPaneID
)
}
}
}
@@ -205,6 +219,7 @@ private struct SplitBranchView: View {
let branch: SplitNode.Branch
let workspace: Workspace
let isActive: Bool
let omittedPaneID: Pane.ID?
/// The fraction previewed while the pointer is down.
///
@@ -218,33 +233,47 @@ private struct SplitBranchView: View {
var body: some View {
GeometryReader { proxy in
let total = length(of: proxy.size)
let firstLength = clampedFirstLength(in: total)
let lengths = childLengths(in: total)
switch branch.axis {
case .horizontal:
HStack(spacing: 0) {
SplitNodeView(node: branch.first, workspace: workspace, isActive: isActive)
.frame(width: firstLength)
divider(total: total)
SplitNodeView(node: branch.second, workspace: workspace, isActive: isActive)
child(branch.first)
.frame(width: lengths.first)
divider(total: total, isCollapsed: lengths.divider == 0)
.frame(width: lengths.divider)
child(branch.second)
.frame(width: lengths.second)
}
case .vertical:
VStack(spacing: 0) {
SplitNodeView(node: branch.first, workspace: workspace, isActive: isActive)
.frame(height: firstLength)
divider(total: total)
SplitNodeView(node: branch.second, workspace: workspace, isActive: isActive)
child(branch.first)
.frame(height: lengths.first)
divider(total: total, isCollapsed: lengths.divider == 0)
.frame(height: lengths.divider)
child(branch.second)
.frame(height: lengths.second)
}
}
}
}
private func child(_ node: SplitNode) -> some View {
SplitNodeView(
node: node,
workspace: workspace,
isActive: isActive,
omittedPaneID: omittedPaneID
)
.clipped()
}
// MARK: - Divider
/// A hairline to look at inside a wider band to grab: one point of visible
/// line is not a pointer target, and widening the line itself would put a
/// grey bar between every pair of panes.
private func divider(total: CGFloat) -> some View {
private func divider(total: CGFloat, isCollapsed: Bool) -> some View {
ZStack {
Color.clear.contentShape(.rect)
Rectangle().fill(.separator).frame(
@@ -253,9 +282,13 @@ private struct SplitBranchView: View {
)
}
.frame(
width: branch.axis == .horizontal ? Metrics.Split.dividerHitLength : nil,
height: branch.axis == .vertical ? Metrics.Split.dividerHitLength : nil
width: branch.axis == .horizontal
? (isCollapsed ? 0 : Metrics.Split.dividerHitLength) : nil,
height: branch.axis == .vertical
? (isCollapsed ? 0 : Metrics.Split.dividerHitLength) : nil
)
.opacity(isCollapsed ? 0 : 1)
.allowsHitTesting(!isCollapsed)
.pointerStyle(branch.axis == .horizontal ? .columnResize : .rowResize)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
@@ -300,6 +333,26 @@ private struct SplitBranchView: View {
usableLength(in: total) * clamp(draggedFraction ?? branch.fraction, in: total)
}
private func childLengths(in total: CGFloat) -> (
first: CGFloat, divider: CGFloat, second: CGFloat
) {
if subtreeDisappearsWhenOmitted(branch.first) {
return (0, 0, total)
}
if subtreeDisappearsWhenOmitted(branch.second) {
return (total, 0, 0)
}
let first = clampedFirstLength(in: total)
let second = max(0, total - first - Metrics.Split.dividerHitLength)
return (first, Metrics.Split.dividerHitLength, second)
}
private func subtreeDisappearsWhenOmitted(_ node: SplitNode) -> Bool {
guard let omittedPaneID, node.pane(omittedPaneID) != nil else { return false }
return node.removing(omittedPaneID) == nil
}
/// Keeps both sides above `minPaneLength` — but only while there is room for
/// that. Below it the clamp inverts and pins the split at the middle, which
/// is the least surprising thing a pane too small for either rule can do.
+26
View File
@@ -546,6 +546,32 @@ struct WindowStateTests {
#expect(window.selectedWorkspace?.id == first.id)
}
@Test
func droppingNestedPaneOnNavigatorDetachesItIntoSelectedWorkspace() throws {
let workspace = Workspace.sshSession(
host: Host(name: "First", host: "first.example")
)
let firstPane = workspace.focusedPane
workspace.splitFocusedPane(
axis: .horizontal,
with: .sshSession(host: Host(name: "Second", host: "second.example"))
)
let detachedPane = workspace.focusedPane
workspace.splitFocusedPane(
axis: .vertical,
with: .sshSession(host: Host(name: "Third", host: "third.example"))
)
let thirdPane = workspace.focusedPane
let window = WindowModel(workspaces: [workspace])
let detachedWorkspace = try #require(window.detachPane(detachedPane.id, at: 0))
#expect(window.workspaces.map(\.id) == [detachedWorkspace.id, workspace.id])
#expect(detachedWorkspace.panes.map(\.id) == [detachedPane.id])
#expect(workspace.panes.map(\.id) == [firstPane.id, thirdPane.id])
#expect(window.selectedWorkspace === detachedWorkspace)
}
@Test
func hostEditorIsVaultNavigationAndCloseTabCannotCloseHiddenWork() {
let host = Host(name: "Server", host: "server.example")