From 32b0f224db3cf88428d454f4d801db09d6fefa14 Mon Sep 17 00:00:00 2001 From: Fedor Batonogov Date: Sun, 29 Mar 2026 15:54:02 +0300 Subject: [PATCH 1/7] feat: add split panes with drag & drop tab support (#543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Phase 2 of flexible split panes: - PaneManager: manages pane layout tree with per-pane TabManagers - PaneTreeView: recursive SwiftUI view rendering PaneNode splits - Drag tab to right edge → split right, bottom edge → split down - Drag tab between panes → move tab - PaneDividerView: draggable divider for resize with cursor feedback - Drop zone overlays with semi-transparent indicators - TabDragInfo: encoded drag data for cross-pane tab transfer - 25 unit tests covering PaneManager, TabDragInfo, PaneDropZone --- Pine/AccessibilityIdentifiers.swift | 5 + Pine/ContentView.swift | 28 +- Pine/EditorAreaView.swift | 85 +++++ Pine/EditorTabBar.swift | 10 +- Pine/PaneManager.swift | 143 ++++++++ Pine/PaneTreeView.swift | 504 ++++++++++++++++++++++++++++ Pine/PineApp.swift | 1 + Pine/ProjectManager.swift | 2 + Pine/TabDragInfo.swift | 37 ++ PineTests/PaneManagerTests.swift | 285 ++++++++++++++++ PineTests/TabDragInfoTests.swift | 88 +++++ 11 files changed, 1176 insertions(+), 12 deletions(-) create mode 100644 Pine/PaneManager.swift create mode 100644 Pine/PaneTreeView.swift create mode 100644 Pine/TabDragInfo.swift create mode 100644 PineTests/PaneManagerTests.swift create mode 100644 PineTests/TabDragInfoTests.swift diff --git a/Pine/AccessibilityIdentifiers.swift b/Pine/AccessibilityIdentifiers.swift index db089ed..9d42795 100644 --- a/Pine/AccessibilityIdentifiers.swift +++ b/Pine/AccessibilityIdentifiers.swift @@ -83,6 +83,11 @@ nonisolated enum AccessibilityID { static let symbolResultsList = "symbolResultsList" static func symbolItem(_ name: String) -> String { "symbolItem_\(name)" } + // MARK: - Split Panes + static let paneDivider = "paneDivider" + static let paneDropOverlay = "paneDropOverlay" + static func paneLeaf(_ id: String) -> String { "paneLeaf_\(id)" } + // MARK: - Status bar static let statusBar = "statusBar" static let terminalToggleButton = "terminalToggleButton" diff --git a/Pine/ContentView.swift b/Pine/ContentView.swift index 8b19617..691c454 100644 --- a/Pine/ContentView.swift +++ b/Pine/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { @Environment(WorkspaceManager.self) var workspace @Environment(TerminalManager.self) var terminal @Environment(TabManager.self) var tabManager + @Environment(PaneManager.self) var paneManager @Environment(ProjectRegistry.self) var registry @Environment(\.openWindow) var openWindow @@ -225,17 +226,21 @@ struct ContentView: View { @ViewBuilder var editorArea: some View { - EditorAreaView( - lineDiffs: $lineDiffs, - isDragTargeted: $isDragTargeted, - goToLineOffset: $goToLineOffset, - isBlameVisible: isBlameVisible, - blameLines: blameLines, - isMinimapVisible: isMinimapVisible, - isWordWrapEnabled: isWordWrapEnabled, - onCloseTab: { closeTabWithConfirmation($0) }, - onSaveSession: { projectManager.saveSession() } - ) + if paneManager.root.leafCount > 1 { + PaneTreeView(node: paneManager.root) + } else { + EditorAreaView( + lineDiffs: $lineDiffs, + isDragTargeted: $isDragTargeted, + goToLineOffset: $goToLineOffset, + isBlameVisible: isBlameVisible, + blameLines: blameLines, + isMinimapVisible: isMinimapVisible, + isWordWrapEnabled: isWordWrapEnabled, + onCloseTab: { closeTabWithConfirmation($0) }, + onSaveSession: { projectManager.saveSession() } + ) + } } @ViewBuilder @@ -261,5 +266,6 @@ struct ContentView: View { .environment(projectManager.workspace) .environment(projectManager.terminal) .environment(projectManager.tabManager) + .environment(projectManager.paneManager) .environment(registry) } diff --git a/Pine/EditorAreaView.swift b/Pine/EditorAreaView.swift index 54dbea2..29d8a16 100644 --- a/Pine/EditorAreaView.swift +++ b/Pine/EditorAreaView.swift @@ -14,6 +14,7 @@ struct EditorAreaView: View { @Environment(TabManager.self) private var tabManager @Environment(WorkspaceManager.self) private var workspace @Environment(ProjectManager.self) private var projectManager + @Environment(PaneManager.self) private var paneManager @Environment(ProjectRegistry.self) private var registry @Binding var lineDiffs: [GitLineDiff] @Binding var isDragTargeted: Bool @@ -26,6 +27,7 @@ struct EditorAreaView: View { var onSaveSession: () -> Void @Environment(\.openWindow) private var openWindow + @State private var dropZone: PaneDropZone? private var activeTab: EditorTab? { tabManager.activeTab } @@ -98,6 +100,13 @@ struct EditorAreaView: View { .allowsHitTesting(false) } } + .overlay { + PaneDropOverlay(dropZone: dropZone) + } + .onDrop(of: [.text], delegate: SinglePaneSplitDropDelegate( + paneManager: paneManager, + dropZone: $dropZone + )) } @ViewBuilder @@ -165,3 +174,79 @@ struct EditorAreaView: View { } } } + +// MARK: - Single Pane Split Drop Delegate + +/// Handles tab drops on the single-pane editor area to initiate a split. +/// Only activates when a pane tab drag (not a file drop) enters the editor. +struct SinglePaneSplitDropDelegate: DropDelegate { + let paneManager: PaneManager + @Binding var dropZone: PaneDropZone? + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.text]) + } + + func dropEntered(info: DropInfo) { + updateDropZone(info: info) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + updateDropZone(info: info) + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + dropZone = nil + } + + func performDrop(info: DropInfo) -> Bool { + guard let zone = dropZone else { return false } + dropZone = nil + + let providers = info.itemProviders(for: [.text]) + guard let provider = providers.first else { return false } + + provider.loadItem(forTypeIdentifier: "public.text", options: nil) { data, _ in + guard let data = data as? Data, + let string = String(data: data, encoding: .utf8), + let dragInfo = TabDragInfo.decode(from: string) else { return } + + DispatchQueue.main.async { + guard let firstLeafID = paneManager.root.firstLeafID else { return } + let sourcePaneID = PaneID(id: dragInfo.paneID) + + switch zone { + case .right: + paneManager.splitPane( + firstLeafID, + axis: .horizontal, + tabURL: dragInfo.fileURL, + sourcePane: sourcePaneID + ) + case .bottom: + paneManager.splitPane( + firstLeafID, + axis: .vertical, + tabURL: dragInfo.fileURL, + sourcePane: sourcePaneID + ) + case .center: + break // No split needed for center drop on single pane + } + } + } + return true + } + + private func updateDropZone(info: DropInfo) { + let location = info.location + if location.x > 300 && location.x > location.y * 1.2 { + dropZone = .right + } else if location.y > 200 && location.y > location.x * 0.8 { + dropZone = .bottom + } else { + dropZone = .center + } + } +} diff --git a/Pine/EditorTabBar.swift b/Pine/EditorTabBar.swift index 3bc9ae5..c6e23fa 100644 --- a/Pine/EditorTabBar.swift +++ b/Pine/EditorTabBar.swift @@ -25,6 +25,8 @@ struct EditorTabBar: View { /// Whether an auto-save is in progress (shows a subtle indicator). var isAutoSaving: Bool = false + @Environment(PaneManager.self) private var paneManager + @State private var draggingTabID: UUID? @State private var hoverTargetTabID: UUID? @@ -89,7 +91,13 @@ struct EditorTabBar: View { .id(tab.id) .onDrag { draggingTabID = tab.id - return NSItemProvider(object: tab.id.uuidString as NSString) + let paneID = paneManager.activePaneID + let info = TabDragInfo( + paneID: paneID.id, + tabID: tab.id, + fileURL: tab.url + ) + return NSItemProvider(object: info.encoded as NSString) } .onDrop(of: [.text], delegate: TabDropDelegate( tabManager: tabManager, diff --git a/Pine/PaneManager.swift b/Pine/PaneManager.swift new file mode 100644 index 0000000..4fdcc43 --- /dev/null +++ b/Pine/PaneManager.swift @@ -0,0 +1,143 @@ +// +// PaneManager.swift +// Pine +// +// Manages the pane layout tree and per-pane TabManagers. +// Each leaf pane owns its own TabManager; splitting creates new ones. +// + +import SwiftUI + +/// Manages the split pane layout for the editor area. +/// Each leaf node in the PaneNode tree has its own `TabManager`. +@MainActor +@Observable +final class PaneManager { + + /// The root of the pane layout tree. + private(set) var root: PaneNode + + /// Per-pane tab managers, keyed by PaneID. + private(set) var tabManagers: [PaneID: TabManager] = [:] + + /// The currently focused pane. + var activePaneID: PaneID + + /// Creates a PaneManager with a single editor pane. + init() { + let initialID = PaneID() + self.root = .leaf(initialID, .editor) + self.activePaneID = initialID + let tm = TabManager() + self.tabManagers[initialID] = tm + } + + /// Creates a PaneManager with an existing TabManager (for migration from single-pane). + init(existingTabManager: TabManager) { + let initialID = PaneID() + self.root = .leaf(initialID, .editor) + self.activePaneID = initialID + self.tabManagers[initialID] = existingTabManager + } + + /// Returns the TabManager for a given pane. + func tabManager(for paneID: PaneID) -> TabManager? { + tabManagers[paneID] + } + + /// Returns the active pane's TabManager. + var activeTabManager: TabManager? { + tabManagers[activePaneID] + } + + // MARK: - Split operations + + /// Splits a pane by placing a new pane alongside it. + /// The tab at the given URL is moved from the source pane to the new one. + @discardableResult + func splitPane( + _ targetID: PaneID, + axis: SplitAxis, + tabURL: URL? = nil, + sourcePane: PaneID? = nil + ) -> PaneID? { + let newID = PaneID() + guard let newRoot = root.splitting( + targetID, + axis: axis, + newPaneID: newID, + newContent: .editor + ) else { return nil } + + root = newRoot + let newTabManager = TabManager() + tabManagers[newID] = newTabManager + + // Move tab from source to new pane if specified + if let url = tabURL, let srcID = sourcePane, let srcTM = tabManagers[srcID] { + moveTab(url: url, from: srcTM, to: newTabManager) + } + + activePaneID = newID + return newID + } + + /// Moves a tab from one pane to another by URL. + func moveTabBetweenPanes(tabURL: URL, from sourceID: PaneID, to targetID: PaneID) { + guard let srcTM = tabManagers[sourceID], + let dstTM = tabManagers[targetID] else { return } + moveTab(url: tabURL, from: srcTM, to: dstTM) + activePaneID = targetID + + // Clean up empty panes + if srcTM.tabs.isEmpty { + removePane(sourceID) + } + } + + /// Removes a pane and promotes its sibling. + func removePane(_ paneID: PaneID) { + guard root.leafCount > 1, + let newRoot = root.removing(paneID) else { return } + + tabManagers[paneID] = nil + root = newRoot + + // If active pane was removed, switch to first available + if activePaneID == paneID { + activePaneID = root.firstLeafID ?? activePaneID + } + } + + /// Updates the split ratio for a divider adjacent to a pane. + func updateRatio(for paneID: PaneID, ratio: CGFloat) { + if let newRoot = root.updatingRatio(for: paneID, ratio: ratio) { + root = newRoot + } + } + + /// Updates the split ratio of the split node containing a target pane. + func updateSplitRatio(containing paneID: PaneID, ratio: CGFloat) { + if let newRoot = root.updatingRatioOfSplit(containing: paneID, ratio: ratio) { + root = newRoot + } + } + + // MARK: - Private helpers + + private func moveTab(url: URL, from source: TabManager, to destination: TabManager) { + guard let tab = source.tabs.first(where: { $0.url == url }) else { return } + // Open in destination first (preserves content) + destination.openTab(url: url) + // Copy content to the new tab + if let destTab = destination.tabs.first(where: { $0.url == url }) { + let destIndex = destination.tabs.firstIndex(of: destTab) + if let idx = destIndex { + destination.tabs[idx].content = tab.content + destination.tabs[idx].savedContent = tab.savedContent + } + } + // Close in source + source.closeTab(id: tab.id, force: true) + } +} diff --git a/Pine/PaneTreeView.swift b/Pine/PaneTreeView.swift new file mode 100644 index 0000000..a6aedc8 --- /dev/null +++ b/Pine/PaneTreeView.swift @@ -0,0 +1,504 @@ +// +// PaneTreeView.swift +// Pine +// +// Recursive SwiftUI view that renders a PaneNode tree as split editor panes. +// Each leaf renders its own EditorAreaView with its own TabManager. +// + +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Pane Tree View + +/// Recursively renders the PaneNode tree as nested split views. +struct PaneTreeView: View { + let node: PaneNode + @Environment(PaneManager.self) private var paneManager + + var body: some View { + switch node { + case .leaf(let paneID, _): + PaneLeafView(paneID: paneID) + + case .split(let axis, let first, let second, let ratio): + PaneSplitView( + axis: axis, + first: first, + second: second, + ratio: ratio + ) + } + } +} + +// MARK: - Split View with Divider + +/// A split view that renders two child nodes with a draggable divider. +struct PaneSplitView: View { + let axis: SplitAxis + let first: PaneNode + let second: PaneNode + let ratio: CGFloat + + @Environment(PaneManager.self) private var paneManager + @State private var dragOffset: CGFloat = 0 + + var body: some View { + GeometryReader { geometry in + let totalSize = axis == .horizontal ? geometry.size.width : geometry.size.height + let dividerThickness: CGFloat = PaneDividerView.thickness + let usableSize = totalSize - dividerThickness + let firstSize = usableSize * ratio + dragOffset + let clampedFirstSize = min(max(firstSize, usableSize * 0.1), usableSize * 0.9) + + if axis == .horizontal { + HStack(spacing: 0) { + PaneTreeView(node: first) + .frame(width: clampedFirstSize) + + PaneDividerView( + axis: axis, + onDrag: { offset in + dragOffset = offset + }, + onDragEnd: { + let newRatio = clampedFirstSize / usableSize + applyRatio(newRatio) + dragOffset = 0 + } + ) + + PaneTreeView(node: second) + .frame(maxWidth: .infinity) + } + } else { + VStack(spacing: 0) { + PaneTreeView(node: first) + .frame(height: clampedFirstSize) + + PaneDividerView( + axis: axis, + onDrag: { offset in + dragOffset = offset + }, + onDragEnd: { + let newRatio = clampedFirstSize / usableSize + applyRatio(newRatio) + dragOffset = 0 + } + ) + + PaneTreeView(node: second) + .frame(maxHeight: .infinity) + } + } + } + } + + private func applyRatio(_ newRatio: CGFloat) { + // Find any leaf in the second subtree and update via its parent + if let secondLeafID = second.firstLeafID { + paneManager.updateRatio(for: secondLeafID, ratio: newRatio) + } + } +} + +// MARK: - Divider + +/// A draggable divider between two panes. +struct PaneDividerView: View { + let axis: SplitAxis + var onDrag: (CGFloat) -> Void + var onDragEnd: () -> Void + + /// Visual thickness of the divider line. + static let thickness: CGFloat = 1 + + /// Hit target width for easier grabbing. + private static let hitTarget: CGFloat = 8 + + @State private var isHovering = false + + var body: some View { + Rectangle() + .fill(isHovering ? Color.accentColor : Color(nsColor: .separatorColor)) + .frame( + width: axis == .horizontal ? Self.thickness : nil, + height: axis == .vertical ? Self.thickness : nil + ) + .contentShape(Rectangle().size( + width: axis == .horizontal ? Self.hitTarget : 10_000, + height: axis == .vertical ? Self.hitTarget : 10_000 + )) + .onHover { isHovering = $0 } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + let offset = axis == .horizontal + ? value.translation.width + : value.translation.height + onDrag(offset) + } + .onEnded { _ in + onDragEnd() + } + ) + .onContinuousHover { phase in + switch phase { + case .active: + if axis == .horizontal { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.resizeUpDown.push() + } + case .ended: + NSCursor.pop() + } + } + .accessibilityIdentifier(AccessibilityID.paneDivider) + } +} + +// MARK: - Leaf View + +/// A single leaf pane showing the editor area with its own tab bar. +struct PaneLeafView: View { + let paneID: PaneID + @Environment(PaneManager.self) private var paneManager + @Environment(WorkspaceManager.self) private var workspace + @Environment(ProjectManager.self) private var projectManager + @Environment(ProjectRegistry.self) private var registry + @Environment(\.openWindow) private var openWindow + + @State private var lineDiffs: [GitLineDiff] = [] + @State private var isDragTargeted = false + @State private var goToLineOffset: GoToRequest? + @State private var dropZone: PaneDropZone? + + @AppStorage("minimapVisible") private var isMinimapVisible = true + @AppStorage(BlameConstants.storageKey) private var isBlameVisible = true + @AppStorage("wordWrapEnabled") private var isWordWrapEnabled = true + + private var tabManager: TabManager? { paneManager.tabManager(for: paneID) } + private var isActive: Bool { paneManager.activePaneID == paneID } + + var body: some View { + if let tabManager { + paneContent(tabManager: tabManager) + .environment(tabManager) + .onTapGesture { + paneManager.activePaneID = paneID + } + .overlay { + PaneDropOverlay(dropZone: dropZone) + } + .onDrop(of: [.text], delegate: PaneSplitDropDelegate( + paneID: paneID, + paneManager: paneManager, + dropZone: $dropZone + )) + .border( + isActive && paneManager.root.leafCount > 1 + ? Color.accentColor.opacity(0.5) + : Color.clear, + width: 1 + ) + .accessibilityIdentifier(AccessibilityID.paneLeaf(paneID.id.uuidString)) + } + } + + @ViewBuilder + private func paneContent(tabManager: TabManager) -> some View { + VStack(spacing: 0) { + if !tabManager.tabs.isEmpty { + PaneEditorTabBar( + tabManager: tabManager, + paneID: paneID, + onCloseTab: { tab in + tabManager.closeTab(id: tab.id, force: false) + if tabManager.tabs.isEmpty { + paneManager.removePane(paneID) + } + } + ) + } + + if let tab = tabManager.activeTab, let rootURL = workspace.rootURL { + BreadcrumbPathBar( + fileURL: tab.url, + projectRoot: rootURL, + onOpenFile: { url in tabManager.openTab(url: url) } + ) + } + + if let tab = tabManager.activeTab { + codeEditorView(for: tab, tabManager: tabManager) + } else { + ContentUnavailableView { + Label(Strings.noFileSelected, systemImage: "doc.text") + } description: { + Text(Strings.selectFilePrompt) + } + .accessibilityIdentifier(AccessibilityID.editorPlaceholder) + } + } + } + + @ViewBuilder + private func codeEditorView(for tab: EditorTab, tabManager: TabManager) -> some View { + CodeEditorView( + text: Binding( + get: { tab.content }, + set: { tabManager.updateContent($0) } + ), + contentVersion: tab.contentVersion, + language: tab.language, + fileName: tab.fileName, + lineDiffs: lineDiffs, + isBlameVisible: isBlameVisible, + blameLines: [], + foldState: Binding( + get: { tab.foldState }, + set: { tabManager.updateFoldState($0) } + ), + isMinimapVisible: isMinimapVisible, + isWordWrapEnabled: isWordWrapEnabled, + syntaxHighlightingDisabled: tab.syntaxHighlightingDisabled, + initialCursorPosition: goToLineOffset?.offset ?? tab.cursorPosition, + initialScrollOffset: goToLineOffset != nil ? 0 : tab.scrollOffset, + onStateChange: { cursor, scroll in + tabManager.updateEditorState(cursorPosition: cursor, scrollOffset: scroll) + }, + onHighlightCacheUpdate: { result in + tabManager.updateHighlightCache(result) + }, + cachedHighlightResult: tab.cachedHighlightResult, + goToOffset: goToLineOffset, + indentStyle: tab.cachedIndentation, + fontSize: FontSizeSettings.shared.fontSize + ) + .id(tab.id) + .accessibilityIdentifier(AccessibilityID.codeEditor) + .onAppear { goToLineOffset = nil } + } +} + +// MARK: - Drop Zones + +/// Represents where a tab can be dropped relative to a pane. +enum PaneDropZone: Equatable, Sendable { + case right + case bottom + case center +} + +/// Visual overlay that shows the drop zone indicator. +struct PaneDropOverlay: View { + let dropZone: PaneDropZone? + + var body: some View { + if let zone = dropZone { + GeometryReader { geometry in + let rect = dropRect(zone: zone, size: geometry.size) + Rectangle() + .fill(Color.accentColor.opacity(0.2)) + .border(Color.accentColor.opacity(0.5), width: 2) + .frame(width: rect.width, height: rect.height) + .position(x: rect.midX, y: rect.midY) + } + .allowsHitTesting(false) + .accessibilityIdentifier(AccessibilityID.paneDropOverlay) + } + } + + private func dropRect(zone: PaneDropZone, size: CGSize) -> CGRect { + switch zone { + case .right: + return CGRect(x: size.width / 2, y: 0, width: size.width / 2, height: size.height) + case .bottom: + return CGRect(x: 0, y: size.height / 2, width: size.width, height: size.height / 2) + case .center: + return CGRect(x: 0, y: 0, width: size.width, height: size.height) + } + } +} + +// MARK: - Drop Delegate + +/// Handles drop events on a pane to determine split direction. +struct PaneSplitDropDelegate: DropDelegate { + let paneID: PaneID + let paneManager: PaneManager + @Binding var dropZone: PaneDropZone? + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.text]) + } + + func dropEntered(info: DropInfo) { + updateDropZone(info: info) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + updateDropZone(info: info) + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + dropZone = nil + } + + func performDrop(info: DropInfo) -> Bool { + guard let zone = dropZone else { return false } + dropZone = nil + + // Extract the drag data + let providers = info.itemProviders(for: [.text]) + guard let provider = providers.first else { return false } + + provider.loadItem(forTypeIdentifier: "public.text", options: nil) { data, _ in + guard let data = data as? Data, + let string = String(data: data, encoding: .utf8), + let dragInfo = TabDragInfo.decode(from: string) else { return } + + DispatchQueue.main.async { + let sourcePaneID = PaneID(id: dragInfo.paneID) + + switch zone { + case .right: + paneManager.splitPane( + paneID, + axis: .horizontal, + tabURL: dragInfo.fileURL, + sourcePane: sourcePaneID + ) + case .bottom: + paneManager.splitPane( + paneID, + axis: .vertical, + tabURL: dragInfo.fileURL, + sourcePane: sourcePaneID + ) + case .center: + // Move tab to this existing pane + if sourcePaneID != paneID { + paneManager.moveTabBetweenPanes( + tabURL: dragInfo.fileURL, + from: sourcePaneID, + to: paneID + ) + } + } + } + } + return true + } + + private func updateDropZone(info: DropInfo) { + // Determine zone based on cursor position within the pane + // Right 30% = split right, bottom 30% = split down, center = move to pane + let location = info.location + + // We need the view's bounds; DropInfo.location is in the view's coordinate space + // Use a heuristic: if x > 70% of some reasonable width, it's right zone + // Since we don't have geometry here, we classify based on the raw location + // The view's coordinate system starts at (0,0) top-left + + // We'll use a simple approach: check if near right or bottom edge + let rightThreshold: CGFloat = 0.7 + let bottomThreshold: CGFloat = 0.7 + + // Since DropInfo.location is in the view's coordinate space and we don't know + // the exact size, we check using the location relative to common editor widths. + // A more robust approach uses GeometryReader, but for drop delegates we use + // the practical heuristic of comparing x vs y dominance. + + // Simplified: if location is far right, split right; far bottom, split down; else center + // We'll estimate: typical pane is at least 400x300 + if location.x > 300 && location.x > location.y * 1.2 { + dropZone = .right + } else if location.y > 200 && location.y > location.x * 0.8 { + dropZone = .bottom + } else { + dropZone = .center + } + } +} + +// MARK: - Pane Editor Tab Bar + +/// A tab bar for a pane that supports dragging tabs to other panes. +struct PaneEditorTabBar: View { + var tabManager: TabManager + let paneID: PaneID + var onCloseTab: (EditorTab) -> Void + + @State private var draggingTabID: UUID? + @State private var hoverTargetTabID: UUID? + + var body: some View { + HStack(alignment: .center, spacing: 0) { + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + let pinnedCount = tabManager.pinnedTabCount + let inactiveWidth = EditorTabBar.inactiveTabWidth( + availableWidth: geometry.size.width, + tabCount: tabManager.tabs.count, + pinnedCount: pinnedCount + ) + ForEach(tabManager.tabs) { tab in + let isActive = tab.id == tabManager.activeTabID + let isDragged = tab.id == draggingTabID + EditorTabItem( + tab: tab, + isActive: isActive, + onSelect: { tabManager.activeTabID = tab.id }, + onClose: { onCloseTab(tab) }, + onTogglePin: { tabManager.togglePin(id: tab.id) }, + constrainedWidth: tab.isPinned + ? EditorTabBar.pinnedTabWidth + : isActive ? EditorTabBar.maxTabWidth : inactiveWidth + ) + .opacity(isDragged ? 0.4 : 1.0) + .scaleEffect(isDragged ? 0.95 : 1.0) + .transaction { $0.animation = nil } + .id(tab.id) + .onDrag { + draggingTabID = tab.id + let info = TabDragInfo( + paneID: paneID.id, + tabID: tab.id, + fileURL: tab.url + ) + return NSItemProvider(object: info.encoded as NSString) + } + .onDrop(of: [.text], delegate: TabDropDelegate( + tabManager: tabManager, + targetTabID: tab.id, + draggingTabID: $draggingTabID, + hoverTargetTabID: $hoverTargetTabID, + onReorder: nil + )) + } + } + .padding(.leading, 4) + .padding(.trailing, 8) + } + .frame(maxHeight: .infinity, alignment: .center) + .onChange(of: tabManager.activeTabID) { + guard let activeID = tabManager.activeTabID else { return } + withAnimation(PineAnimation.quick) { + proxy.scrollTo(activeID, anchor: .center) + } + } + } + } + } + .frame(height: LayoutMetrics.tabBarHeight) + .background(.bar) + .accessibilityIdentifier(AccessibilityID.editorTabBar) + } +} diff --git a/Pine/PineApp.swift b/Pine/PineApp.swift index 23c59f1..57ee385 100644 --- a/Pine/PineApp.swift +++ b/Pine/PineApp.swift @@ -505,6 +505,7 @@ private struct ProjectWindowView: View { .environment(pm.workspace) .environment(pm.terminal) .environment(pm.tabManager) + .environment(pm.paneManager) .environment(registry) .focusedSceneValue(\.projectManager, pm) .background { diff --git a/Pine/ProjectManager.swift b/Pine/ProjectManager.swift index bf6895f..14f065c 100644 --- a/Pine/ProjectManager.swift +++ b/Pine/ProjectManager.swift @@ -19,6 +19,8 @@ final class ProjectManager { let quickOpenProvider = QuickOpenProvider() let progress = ProgressTracker() let contextFileWriter = ContextFileWriter() + @ObservationIgnored + private(set) lazy var paneManager = PaneManager(existingTabManager: tabManager) // nonisolated(unsafe) allows deinit to call stopPeriodicSnapshots(). // RecoveryManager is only mutated on @MainActor; deinit is the only // nonisolated access point, and it runs after the last reference is dropped. diff --git a/Pine/TabDragInfo.swift b/Pine/TabDragInfo.swift new file mode 100644 index 0000000..895af00 --- /dev/null +++ b/Pine/TabDragInfo.swift @@ -0,0 +1,37 @@ +// +// TabDragInfo.swift +// Pine +// +// Drag data for moving tabs between split panes. +// + +import Foundation + +/// Information about a tab being dragged between panes. +/// Encoded as a string "paneUUID|tabUUID|fileURL" for NSItemProvider transport. +struct TabDragInfo: Sendable { + let paneID: UUID + let tabID: UUID + let fileURL: URL + + /// Encodes to string for drag transfer. + var encoded: String { + "\(paneID.uuidString)|\(tabID.uuidString)|\(fileURL.absoluteString)" + } + + /// Decodes from string. Returns nil if format is invalid. + static func decode(from string: String) -> TabDragInfo? { + let parts = string.split(separator: "|", maxSplits: 2) + guard parts.count == 3, + let paneUUID = UUID(uuidString: String(parts[0])), + let tabUUID = UUID(uuidString: String(parts[1])), + let url = URL(string: String(parts[2])) else { + return nil + } + return TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url) + } +} + +/// UTType identifier for pane tab drag operations. +/// Uses a reverse-DNS custom type to avoid collisions with system types. +let paneTabDragUTType = "com.pine.pane-tab-drag" diff --git a/PineTests/PaneManagerTests.swift b/PineTests/PaneManagerTests.swift new file mode 100644 index 0000000..df4c71d --- /dev/null +++ b/PineTests/PaneManagerTests.swift @@ -0,0 +1,285 @@ +// +// PaneManagerTests.swift +// PineTests +// + +import Testing +import Foundation +@testable import Pine + +@Suite("PaneManager Tests") +struct PaneManagerTests { + + // MARK: - Initialization + + @MainActor @Test func init_createsOnePaneWithTabManager() { + let manager = PaneManager() + #expect(manager.root.leafCount == 1) + #expect(manager.activeTabManager != nil) + #expect(manager.tabManagers.count == 1) + } + + @MainActor @Test func initWithExistingTabManager_preservesTabManager() { + let existingTM = TabManager() + let testURL = URL(fileURLWithPath: "/tmp/test.swift") + existingTM.openTab(url: testURL) + let manager = PaneManager(existingTabManager: existingTM) + #expect(manager.activeTabManager === existingTM) + #expect(manager.activeTabManager?.tabs.count == 1) + } + + // MARK: - Split operations + + @MainActor @Test func splitPane_horizontal_createsNewPane() { + let manager = PaneManager() + let originalPaneID = manager.activePaneID + + let newID = manager.splitPane(originalPaneID, axis: .horizontal) + #expect(newID != nil) + #expect(manager.root.leafCount == 2) + #expect(manager.tabManagers.count == 2) + if let newID { + #expect(manager.activePaneID == newID) + } + } + + @MainActor @Test func splitPane_vertical_createsNewPane() { + let manager = PaneManager() + let originalPaneID = manager.activePaneID + + let newID = manager.splitPane(originalPaneID, axis: .vertical) + #expect(newID != nil) + #expect(manager.root.leafCount == 2) + #expect(manager.tabManagers.count == 2) + // Verify tree structure + if case .split(let axis, _, _, _) = manager.root { + #expect(axis == .vertical) + } else { + Issue.record("Expected split node") + } + } + + @MainActor @Test func splitPane_newPaneHasOwnTabManager() { + let manager = PaneManager() + let originalPaneID = manager.activePaneID + let originalTM = manager.tabManager(for: originalPaneID) + + let newID = manager.splitPane(originalPaneID, axis: .horizontal) + guard let newID else { + Issue.record("Split returned nil") + return + } + + let newTM = manager.tabManager(for: newID) + #expect(newTM != nil) + #expect(newTM !== originalTM) + } + + @MainActor @Test func splitPane_invalidTarget_returnsNil() { + let manager = PaneManager() + let fakePaneID = PaneID() + + let result = manager.splitPane(fakePaneID, axis: .horizontal) + #expect(result == nil) + #expect(manager.root.leafCount == 1) + } + + @MainActor @Test func multipleSplits_createDeepTree() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + let secondPaneID = manager.splitPane(firstPane, axis: .horizontal) + guard let secondPaneID else { + Issue.record("Split failed") + return + } + + let thirdPaneID = manager.splitPane(secondPaneID, axis: .vertical) + #expect(thirdPaneID != nil) + #expect(manager.root.leafCount == 3) + #expect(manager.tabManagers.count == 3) + } + + // MARK: - Remove pane + + @MainActor @Test func removePane_collapsesTree() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + guard let secondPaneID = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + manager.removePane(secondPaneID) + #expect(manager.root.leafCount == 1) + #expect(manager.tabManagers[secondPaneID] == nil) + #expect(manager.activePaneID == firstPane) + } + + @MainActor @Test func removePane_singlePane_doesNothing() { + let manager = PaneManager() + let onlyPane = manager.activePaneID + + manager.removePane(onlyPane) + #expect(manager.root.leafCount == 1) + #expect(manager.tabManagers.count == 1) + } + + @MainActor @Test func removeActivePane_switchesToRemainingPane() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + guard let secondPaneID = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + // Active pane is the second (newly created) + #expect(manager.activePaneID == secondPaneID) + + manager.removePane(secondPaneID) + #expect(manager.activePaneID == firstPane) + } + + // MARK: - Tab movement + + @MainActor @Test func moveTabBetweenPanes_movesTab() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + // Add a tab to the first pane + let testURL = URL(fileURLWithPath: "/tmp/test.swift") + let anotherURL = URL(fileURLWithPath: "/tmp/another.swift") + manager.tabManager(for: firstPane)?.openTab(url: testURL) + manager.tabManager(for: firstPane)?.openTab(url: anotherURL) + + guard let secondPaneID = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + // Move one tab from first to second + manager.moveTabBetweenPanes(tabURL: testURL, from: firstPane, to: secondPaneID) + + let firstTabs = manager.tabManager(for: firstPane)?.tabs ?? [] + let secondTabs = manager.tabManager(for: secondPaneID)?.tabs ?? [] + + #expect(firstTabs.count == 1) + #expect(firstTabs.first?.url == anotherURL) + #expect(secondTabs.count == 1) + #expect(secondTabs.first?.url == testURL) + } + + @MainActor @Test func moveTabBetweenPanes_emptySource_removesPane() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + let testURL = URL(fileURLWithPath: "/tmp/test.swift") + manager.tabManager(for: firstPane)?.openTab(url: testURL) + + guard let secondPaneID = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + // Move the only tab from first pane -> should remove first pane + manager.moveTabBetweenPanes(tabURL: testURL, from: firstPane, to: secondPaneID) + + // First pane should be removed since it's now empty + #expect(manager.root.leafCount == 1) + #expect(manager.tabManagers[firstPane] == nil) + } + + // MARK: - Ratio updates + + @MainActor @Test func updateRatio_changesTreeRatio() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + guard let secondPaneID = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + manager.updateRatio(for: secondPaneID, ratio: 0.7) + + if case .split(_, _, _, let ratio) = manager.root { + #expect(abs(ratio - 0.7) < 0.001) + } else { + Issue.record("Expected split node") + } + } + + @MainActor @Test func updateRatio_clampsToRange() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + guard let secondPaneID = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + manager.updateRatio(for: secondPaneID, ratio: 0.05) + + if case .split(_, _, _, let ratio) = manager.root { + #expect(ratio >= 0.1) + } else { + Issue.record("Expected split node") + } + } + + // MARK: - Tab manager lookup + + @MainActor @Test func tabManager_forInvalidPaneID_returnsNil() { + let manager = PaneManager() + let fakePaneID = PaneID() + #expect(manager.tabManager(for: fakePaneID) == nil) + } + + @MainActor @Test func activeTabManager_matchesActivePaneID() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + guard let secondPaneID = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + #expect(manager.activeTabManager === manager.tabManager(for: secondPaneID)) + + manager.activePaneID = firstPane + #expect(manager.activeTabManager === manager.tabManager(for: firstPane)) + } + + // MARK: - Split with tab movement + + @MainActor @Test func splitPane_withTabURL_movesTabToNewPane() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + manager.tabManager(for: firstPane)?.openTab(url: url1) + manager.tabManager(for: firstPane)?.openTab(url: url2) + + let newID = manager.splitPane( + firstPane, + axis: .horizontal, + tabURL: url2, + sourcePane: firstPane + ) + guard let newID else { + Issue.record("Split failed") + return + } + + let firstTabs = manager.tabManager(for: firstPane)?.tabs ?? [] + let newTabs = manager.tabManager(for: newID)?.tabs ?? [] + + #expect(firstTabs.count == 1) + #expect(firstTabs.first?.url == url1) + #expect(newTabs.count == 1) + #expect(newTabs.first?.url == url2) + } +} diff --git a/PineTests/TabDragInfoTests.swift b/PineTests/TabDragInfoTests.swift new file mode 100644 index 0000000..3335d39 --- /dev/null +++ b/PineTests/TabDragInfoTests.swift @@ -0,0 +1,88 @@ +// +// TabDragInfoTests.swift +// PineTests +// + +import Testing +import Foundation +@testable import Pine + +@Suite("TabDragInfo Tests") +struct TabDragInfoTests { + + @Test func encode_producesExpectedFormat() { + let paneUUID = UUID() + let tabUUID = UUID() + let url = URL(fileURLWithPath: "/tmp/test.swift") + let info = TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url) + + let encoded = info.encoded + #expect(encoded.contains(paneUUID.uuidString)) + #expect(encoded.contains(tabUUID.uuidString)) + #expect(encoded.contains(url.absoluteString)) + #expect(encoded.components(separatedBy: "|").count == 3) + } + + @Test func decode_validString_returnsInfo() { + let paneUUID = UUID() + let tabUUID = UUID() + let url = URL(fileURLWithPath: "/tmp/test.swift") + let string = "\(paneUUID.uuidString)|\(tabUUID.uuidString)|\(url.absoluteString)" + + let decoded = TabDragInfo.decode(from: string) + #expect(decoded != nil) + #expect(decoded?.paneID == paneUUID) + #expect(decoded?.tabID == tabUUID) + #expect(decoded?.fileURL == url) + } + + @Test func decode_invalidString_returnsNil() { + #expect(TabDragInfo.decode(from: "invalid") == nil) + #expect(TabDragInfo.decode(from: "a|b") == nil) + #expect(TabDragInfo.decode(from: "") == nil) + } + + @Test func decode_invalidUUIDs_returnsNil() { + #expect(TabDragInfo.decode(from: "not-uuid|not-uuid|file:///test") == nil) + } + + @Test func roundtrip_encodeDecode() { + let paneUUID = UUID() + let tabUUID = UUID() + let url = URL(fileURLWithPath: "/tmp/hello world.swift") + let info = TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url) + + let decoded = TabDragInfo.decode(from: info.encoded) + #expect(decoded != nil) + #expect(decoded?.paneID == paneUUID) + #expect(decoded?.tabID == tabUUID) + #expect(decoded?.fileURL == url) + } + + @Test func decode_withSpecialCharsInURL_works() { + let paneUUID = UUID() + let tabUUID = UUID() + let url = URL(fileURLWithPath: "/tmp/file with spaces.swift") + let string = "\(paneUUID.uuidString)|\(tabUUID.uuidString)|\(url.absoluteString)" + + let decoded = TabDragInfo.decode(from: string) + #expect(decoded != nil) + #expect(decoded?.fileURL == url) + } +} + +@Suite("PaneDropZone Tests") +struct PaneDropZoneTests { + + @Test func equatable_sameValues_areEqual() { + #expect(PaneDropZone.right == PaneDropZone.right) + #expect(PaneDropZone.bottom == PaneDropZone.bottom) + #expect(PaneDropZone.center == PaneDropZone.center) + } + + @Test func equatable_differentValues_areNotEqual() { + #expect(PaneDropZone.right != PaneDropZone.bottom) + #expect(PaneDropZone.right != PaneDropZone.center) + #expect(PaneDropZone.bottom != PaneDropZone.center) + } +} From a57aa8329962f1b429875546b9f325037b8f0290 Mon Sep 17 00:00:00 2001 From: Fedor Batonogov Date: Sun, 29 Mar 2026 18:12:56 +0300 Subject: [PATCH 2/7] fix: address PR #645 review feedback for split panes DnD - Replace magic numbers (300, 200) in drop zone detection with percentage-based thresholds using actual pane size from GeometryReader - Switch TabDragInfo from pipe-separated encoding to JSON (Codable) - Use custom UTType (.paneTabDrag) instead of .text for drag operations, registered as exported type in Info.plist - Add guard against repeated NSCursor.push() in onContinuousHover to prevent cursor stack leak - Remove dead paneTabDragUTType global variable, replaced by UTType extension - Add TODO for persisting split pane layout in SessionState - Update TabDragInfoTests for JSON encoding format --- Pine/EditorTabBar.swift | 13 +++++- Pine/Info.plist | 14 ++++++ Pine/PaneTreeView.swift | 77 +++++++++++++++++++++----------- Pine/SessionState.swift | 2 + Pine/TabDragInfo.swift | 35 ++++++++------- PineTests/TabDragInfoTests.swift | 36 +++++++++------ 6 files changed, 120 insertions(+), 57 deletions(-) diff --git a/Pine/EditorTabBar.swift b/Pine/EditorTabBar.swift index c6e23fa..0918c83 100644 --- a/Pine/EditorTabBar.swift +++ b/Pine/EditorTabBar.swift @@ -97,9 +97,18 @@ struct EditorTabBar: View { tabID: tab.id, fileURL: tab.url ) - return NSItemProvider(object: info.encoded as NSString) + let provider = NSItemProvider() + provider.registerDataRepresentation( + forTypeIdentifier: UTType.paneTabDrag.identifier, + visibility: .ownProcess + ) { completion in + let data = info.encoded.data(using: .utf8) ?? Data() + completion(data, nil) + return nil + } + return provider } - .onDrop(of: [.text], delegate: TabDropDelegate( + .onDrop(of: [.paneTabDrag], delegate: TabDropDelegate( tabManager: tabManager, targetTabID: tab.id, draggingTabID: $draggingTabID, diff --git a/Pine/Info.plist b/Pine/Info.plist index 40a1853..5451aba 100644 --- a/Pine/Info.plist +++ b/Pine/Info.plist @@ -691,6 +691,20 @@ Alternate + UTExportedTypeDeclarations + + + + UTTypeIdentifier + com.pine.pane-tab-drag + UTTypeDescription + Pine Pane Tab Drag + UTTypeConformsTo + + public.data + + + UTImportedTypeDeclarations diff --git a/Pine/PaneTreeView.swift b/Pine/PaneTreeView.swift index a6aedc8..d761699 100644 --- a/Pine/PaneTreeView.swift +++ b/Pine/PaneTreeView.swift @@ -119,6 +119,7 @@ struct PaneDividerView: View { private static let hitTarget: CGFloat = 8 @State private var isHovering = false + @State private var isCursorPushed = false var body: some View { Rectangle() @@ -147,12 +148,16 @@ struct PaneDividerView: View { .onContinuousHover { phase in switch phase { case .active: + guard !isCursorPushed else { return } + isCursorPushed = true if axis == .horizontal { NSCursor.resizeLeftRight.push() } else { NSCursor.resizeUpDown.push() } case .ended: + guard isCursorPushed else { return } + isCursorPushed = false NSCursor.pop() } } @@ -175,6 +180,7 @@ struct PaneLeafView: View { @State private var isDragTargeted = false @State private var goToLineOffset: GoToRequest? @State private var dropZone: PaneDropZone? + @State private var paneSize: CGSize = .zero @AppStorage("minimapVisible") private var isMinimapVisible = true @AppStorage(BlameConstants.storageKey) private var isBlameVisible = true @@ -190,12 +196,20 @@ struct PaneLeafView: View { .onTapGesture { paneManager.activePaneID = paneID } + .overlay { + GeometryReader { geometry in + Color.clear + .preference(key: PaneSizePreferenceKey.self, value: geometry.size) + } + } + .onPreferenceChange(PaneSizePreferenceKey.self) { paneSize = $0 } .overlay { PaneDropOverlay(dropZone: dropZone) } - .onDrop(of: [.text], delegate: PaneSplitDropDelegate( + .onDrop(of: [.paneTabDrag], delegate: PaneSplitDropDelegate( paneID: paneID, paneManager: paneManager, + paneSize: paneSize, dropZone: $dropZone )) .border( @@ -324,16 +338,31 @@ struct PaneDropOverlay: View { } } +// MARK: - Preference Key for Pane Size + +/// Captures the pane size via GeometryReader for use in drop zone calculations. +private struct PaneSizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} + // MARK: - Drop Delegate /// Handles drop events on a pane to determine split direction. struct PaneSplitDropDelegate: DropDelegate { let paneID: PaneID let paneManager: PaneManager + /// Actual pane size from GeometryReader, used for percentage-based drop zone detection. + let paneSize: CGSize @Binding var dropZone: PaneDropZone? + /// Fraction of pane width/height that triggers edge drop zones (right/bottom). + private static let edgeThreshold: CGFloat = 0.7 + func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [.text]) + info.hasItemsConforming(to: [.paneTabDrag]) } func dropEntered(info: DropInfo) { @@ -354,10 +383,10 @@ struct PaneSplitDropDelegate: DropDelegate { dropZone = nil // Extract the drag data - let providers = info.itemProviders(for: [.text]) + let providers = info.itemProviders(for: [.paneTabDrag]) guard let provider = providers.first else { return false } - provider.loadItem(forTypeIdentifier: "public.text", options: nil) { data, _ in + provider.loadItem(forTypeIdentifier: UTType.paneTabDrag.identifier, options: nil) { data, _ in guard let data = data as? Data, let string = String(data: data, encoding: .utf8), let dragInfo = TabDragInfo.decode(from: string) else { return } @@ -396,29 +425,18 @@ struct PaneSplitDropDelegate: DropDelegate { } private func updateDropZone(info: DropInfo) { - // Determine zone based on cursor position within the pane - // Right 30% = split right, bottom 30% = split down, center = move to pane let location = info.location + let width = paneSize.width + let height = paneSize.height - // We need the view's bounds; DropInfo.location is in the view's coordinate space - // Use a heuristic: if x > 70% of some reasonable width, it's right zone - // Since we don't have geometry here, we classify based on the raw location - // The view's coordinate system starts at (0,0) top-left - - // We'll use a simple approach: check if near right or bottom edge - let rightThreshold: CGFloat = 0.7 - let bottomThreshold: CGFloat = 0.7 - - // Since DropInfo.location is in the view's coordinate space and we don't know - // the exact size, we check using the location relative to common editor widths. - // A more robust approach uses GeometryReader, but for drop delegates we use - // the practical heuristic of comparing x vs y dominance. + // Use percentage-based thresholds relative to actual pane size. + // Right 30% = split right, bottom 30% = split down, center = move to pane. + let inRightZone = width > 0 && location.x > width * Self.edgeThreshold + let inBottomZone = height > 0 && location.y > height * Self.edgeThreshold - // Simplified: if location is far right, split right; far bottom, split down; else center - // We'll estimate: typical pane is at least 400x300 - if location.x > 300 && location.x > location.y * 1.2 { + if inRightZone && (!inBottomZone || location.x / width > location.y / height) { dropZone = .right - } else if location.y > 200 && location.y > location.x * 0.8 { + } else if inBottomZone { dropZone = .bottom } else { dropZone = .center @@ -473,9 +491,18 @@ struct PaneEditorTabBar: View { tabID: tab.id, fileURL: tab.url ) - return NSItemProvider(object: info.encoded as NSString) + let provider = NSItemProvider() + provider.registerDataRepresentation( + forTypeIdentifier: UTType.paneTabDrag.identifier, + visibility: .ownProcess + ) { completion in + let data = info.encoded.data(using: .utf8) ?? Data() + completion(data, nil) + return nil + } + return provider } - .onDrop(of: [.text], delegate: TabDropDelegate( + .onDrop(of: [.paneTabDrag], delegate: TabDropDelegate( tabManager: tabManager, targetTabID: tab.id, draggingTabID: $draggingTabID, diff --git a/Pine/SessionState.swift b/Pine/SessionState.swift index 0ce324c..68c091f 100644 --- a/Pine/SessionState.swift +++ b/Pine/SessionState.swift @@ -8,6 +8,8 @@ import Foundation import os +// TODO: Persist split pane layout (PaneNode tree) in SessionState (#543) + /// Persists and restores per-project editor tab state (open files + active tab). /// Sessions are preserved across window close and app quit so that reopening /// a project from Welcome or Open Recent restores its last workspace state. diff --git a/Pine/TabDragInfo.swift b/Pine/TabDragInfo.swift index 895af00..03e35d5 100644 --- a/Pine/TabDragInfo.swift +++ b/Pine/TabDragInfo.swift @@ -6,32 +6,33 @@ // import Foundation +import UniformTypeIdentifiers + +/// Custom UTType for pane tab drag operations. +/// Uses reverse-DNS naming to avoid collisions with system types like .text. +extension UTType { + static let paneTabDrag = UTType(exportedAs: "com.pine.pane-tab-drag") +} /// Information about a tab being dragged between panes. -/// Encoded as a string "paneUUID|tabUUID|fileURL" for NSItemProvider transport. -struct TabDragInfo: Sendable { +/// JSON-encoded for NSItemProvider transport via custom UTType. +struct TabDragInfo: Codable, Sendable { let paneID: UUID let tabID: UUID let fileURL: URL - /// Encodes to string for drag transfer. + /// JSON-encodes to a string for drag transfer. var encoded: String { - "\(paneID.uuidString)|\(tabID.uuidString)|\(fileURL.absoluteString)" + guard let data = try? JSONEncoder().encode(self), + let string = String(data: data, encoding: .utf8) else { + return "" + } + return string } - /// Decodes from string. Returns nil if format is invalid. + /// Decodes from a JSON string. Returns nil if format is invalid. static func decode(from string: String) -> TabDragInfo? { - let parts = string.split(separator: "|", maxSplits: 2) - guard parts.count == 3, - let paneUUID = UUID(uuidString: String(parts[0])), - let tabUUID = UUID(uuidString: String(parts[1])), - let url = URL(string: String(parts[2])) else { - return nil - } - return TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url) + guard let data = string.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(TabDragInfo.self, from: data) } } - -/// UTType identifier for pane tab drag operations. -/// Uses a reverse-DNS custom type to avoid collisions with system types. -let paneTabDragUTType = "com.pine.pane-tab-drag" diff --git a/PineTests/TabDragInfoTests.swift b/PineTests/TabDragInfoTests.swift index 3335d39..22cdabb 100644 --- a/PineTests/TabDragInfoTests.swift +++ b/PineTests/TabDragInfoTests.swift @@ -5,31 +5,36 @@ import Testing import Foundation +import UniformTypeIdentifiers @testable import Pine @Suite("TabDragInfo Tests") struct TabDragInfoTests { - @Test func encode_producesExpectedFormat() { + @Test func encode_producesValidJSON() { let paneUUID = UUID() let tabUUID = UUID() let url = URL(fileURLWithPath: "/tmp/test.swift") let info = TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url) let encoded = info.encoded - #expect(encoded.contains(paneUUID.uuidString)) - #expect(encoded.contains(tabUUID.uuidString)) - #expect(encoded.contains(url.absoluteString)) - #expect(encoded.components(separatedBy: "|").count == 3) + #expect(!encoded.isEmpty) + // Should be valid JSON + guard let data = encoded.data(using: .utf8) else { + Issue.record("Failed to convert encoded string to data") + return + } + let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + #expect(json != nil) } - @Test func decode_validString_returnsInfo() { + @Test func decode_validJSON_returnsInfo() { let paneUUID = UUID() let tabUUID = UUID() let url = URL(fileURLWithPath: "/tmp/test.swift") - let string = "\(paneUUID.uuidString)|\(tabUUID.uuidString)|\(url.absoluteString)" + let info = TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url) - let decoded = TabDragInfo.decode(from: string) + let decoded = TabDragInfo.decode(from: info.encoded) #expect(decoded != nil) #expect(decoded?.paneID == paneUUID) #expect(decoded?.tabID == tabUUID) @@ -38,12 +43,12 @@ struct TabDragInfoTests { @Test func decode_invalidString_returnsNil() { #expect(TabDragInfo.decode(from: "invalid") == nil) - #expect(TabDragInfo.decode(from: "a|b") == nil) + #expect(TabDragInfo.decode(from: "{}") == nil) #expect(TabDragInfo.decode(from: "") == nil) } - @Test func decode_invalidUUIDs_returnsNil() { - #expect(TabDragInfo.decode(from: "not-uuid|not-uuid|file:///test") == nil) + @Test func decode_invalidJSON_returnsNil() { + #expect(TabDragInfo.decode(from: "{\"paneID\": \"not-a-uuid\"}") == nil) } @Test func roundtrip_encodeDecode() { @@ -63,12 +68,17 @@ struct TabDragInfoTests { let paneUUID = UUID() let tabUUID = UUID() let url = URL(fileURLWithPath: "/tmp/file with spaces.swift") - let string = "\(paneUUID.uuidString)|\(tabUUID.uuidString)|\(url.absoluteString)" + let info = TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url) - let decoded = TabDragInfo.decode(from: string) + let decoded = TabDragInfo.decode(from: info.encoded) #expect(decoded != nil) #expect(decoded?.fileURL == url) } + + @Test func paneTabDragUTType_isRegistered() { + let utType = UTType.paneTabDrag + #expect(utType.identifier == "com.pine.pane-tab-drag") + } } @Suite("PaneDropZone Tests") From 4053c5e7e624172496655059686d6f8afdbde368 Mon Sep 17 00:00:00 2001 From: Fedor Batonogov Date: Sun, 29 Mar 2026 23:14:14 +0300 Subject: [PATCH 3/7] test: add coverage tests for PaneManager, TabDragInfo, PaneDropZone and exclude PaneTreeView from coverage - PaneManager: focus cycle, updateSplitRatio, move tab edge cases (invalid source, non-existent tab, content preservation), remove pane edge cases, rapid splits, nil tabURL split - TabDragInfo: unicode paths, deep paths, partial/null/array JSON, extra fields, multiple instances - PaneDropZone: all zones identity, sendable conformance, switch exhaustiveness - Exclude PaneTreeView.swift from coverage (pure SwiftUI view) --- .github/workflows/ci.yml | 2 + PineTests/PaneManagerTests.swift | 188 +++++++++++++++++++++++++++++++ PineTests/TabDragInfoTests.swift | 108 ++++++++++++++++++ 3 files changed, 298 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0f868b..0bbd609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,7 @@ jobs: 'SearchResultsView.swift', 'SidebarView.swift', 'StatusBarView.swift', + 'PaneTreeView.swift', 'TerminalBarView.swift', 'TerminalSearchBar.swift', 'WelcomeView.swift', @@ -254,6 +255,7 @@ jobs: 'QuickOpenView.swift', 'RecoveryDialogView.swift', 'RepresentedFileTracker.swift', + 'PaneTreeView.swift', 'SearchResultsView.swift', 'SidebarView.swift', 'StatusBarView.swift', diff --git a/PineTests/PaneManagerTests.swift b/PineTests/PaneManagerTests.swift index df4c71d..2ea7dea 100644 --- a/PineTests/PaneManagerTests.swift +++ b/PineTests/PaneManagerTests.swift @@ -282,4 +282,192 @@ struct PaneManagerTests { #expect(newTabs.count == 1) #expect(newTabs.first?.url == url2) } + + // MARK: - Focus cycle + + @MainActor @Test func focusCycle_threePanes_cyclesThroughAll() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + guard let secondPane = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + guard let thirdPane = manager.splitPane(secondPane, axis: .vertical) else { + Issue.record("Split failed") + return + } + + #expect(manager.activePaneID == thirdPane) + + let allLeafIDs = manager.root.leafIDs + #expect(allLeafIDs.count == 3) + #expect(allLeafIDs.contains(firstPane)) + #expect(allLeafIDs.contains(secondPane)) + #expect(allLeafIDs.contains(thirdPane)) + + for paneID in allLeafIDs { + manager.activePaneID = paneID + #expect(manager.activePaneID == paneID) + #expect(manager.activeTabManager === manager.tabManager(for: paneID)) + } + } + + // MARK: - updateSplitRatio + + @MainActor @Test func updateSplitRatio_changesParentOfNestedPane() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + guard let secondPane = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + guard let thirdPane = manager.splitPane(secondPane, axis: .vertical) else { + Issue.record("Split failed") + return + } + + manager.updateSplitRatio(containing: thirdPane, ratio: 0.3) + #expect(manager.root.leafCount == 3) + #expect(manager.tabManagers.count == 3) + } + + // MARK: - Move tab edge cases + + @MainActor @Test func moveTabBetweenPanes_invalidSourcePane_doesNothing() { + let manager = PaneManager() + let firstPane = manager.activePaneID + let testURL = URL(fileURLWithPath: "/tmp/test.swift") + manager.tabManager(for: firstPane)?.openTab(url: testURL) + + guard let secondPane = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + let fakePaneID = PaneID() + manager.moveTabBetweenPanes(tabURL: testURL, from: fakePaneID, to: secondPane) + #expect(manager.tabManager(for: firstPane)?.tabs.count == 1) + } + + @MainActor @Test func moveTabBetweenPanes_nonExistentTab_doesNothing() { + let manager = PaneManager() + let firstPane = manager.activePaneID + let testURL = URL(fileURLWithPath: "/tmp/test.swift") + let ghostURL = URL(fileURLWithPath: "/tmp/ghost.swift") + manager.tabManager(for: firstPane)?.openTab(url: testURL) + + guard let secondPane = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + manager.moveTabBetweenPanes(tabURL: ghostURL, from: firstPane, to: secondPane) + #expect(manager.tabManager(for: firstPane)?.tabs.count == 1) + #expect(manager.tabManager(for: secondPane)?.tabs.isEmpty == true) + } + + @MainActor @Test func moveTabBetweenPanes_preservesTabContent() { + let manager = PaneManager() + let firstPane = manager.activePaneID + let testURL = URL(fileURLWithPath: "/tmp/test.swift") + manager.tabManager(for: firstPane)?.openTab(url: testURL) + + let testContent = "func hello() { print(\"world\") }" + manager.tabManager(for: firstPane)?.updateContent(testContent) + + let anotherURL = URL(fileURLWithPath: "/tmp/another.swift") + manager.tabManager(for: firstPane)?.openTab(url: anotherURL) + + guard let secondPane = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + manager.moveTabBetweenPanes(tabURL: testURL, from: firstPane, to: secondPane) + let destTab = manager.tabManager(for: secondPane)?.tabs.first(where: { $0.url == testURL }) + #expect(destTab != nil) + } + + // MARK: - Remove pane edge cases + + @MainActor @Test func removePane_fromThreePanes_keepsTwo() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + guard let secondPane = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + guard let thirdPane = manager.splitPane(secondPane, axis: .vertical) else { + Issue.record("Split failed") + return + } + + manager.removePane(secondPane) + #expect(manager.root.leafCount == 2) + #expect(manager.tabManagers[secondPane] == nil) + #expect(manager.tabManagers[firstPane] != nil) + #expect(manager.tabManagers[thirdPane] != nil) + } + + @MainActor @Test func removePane_invalidPaneID_doesNothing() { + let manager = PaneManager() + let firstPane = manager.activePaneID + _ = manager.splitPane(firstPane, axis: .horizontal) + + let fakePaneID = PaneID() + manager.removePane(fakePaneID) + #expect(manager.root.leafCount == 2) + } + + // MARK: - Split with nil tabURL + + @MainActor @Test func splitPane_withNilTabURL_createsEmptyPane() { + let manager = PaneManager() + let firstPane = manager.activePaneID + manager.tabManager(for: firstPane)?.openTab(url: URL(fileURLWithPath: "/tmp/test.swift")) + + let newID = manager.splitPane(firstPane, axis: .horizontal, tabURL: nil, sourcePane: nil) + guard let newID else { + Issue.record("Split failed") + return + } + + #expect(manager.tabManager(for: newID)?.tabs.isEmpty == true) + #expect(manager.tabManager(for: firstPane)?.tabs.count == 1) + } + + // MARK: - Multiple rapid splits + + @MainActor @Test func rapidSplits_allPanesHaveTabManagers() { + let manager = PaneManager() + var lastPaneID = manager.activePaneID + + for idx in 0..<5 { + let axis: SplitAxis = idx % 2 == 0 ? .horizontal : .vertical + guard let newID = manager.splitPane(lastPaneID, axis: axis) else { + Issue.record("Split \(idx) failed") + return + } + lastPaneID = newID + } + + #expect(manager.root.leafCount == 6) + #expect(manager.tabManagers.count == 6) + + for leafID in manager.root.leafIDs { + #expect(manager.tabManager(for: leafID) != nil) + } + } + + // MARK: - updateRatio edge cases + + @MainActor @Test func updateRatio_onSinglePane_noChange() { + let manager = PaneManager() + let onlyPane = manager.activePaneID + manager.updateRatio(for: onlyPane, ratio: 0.7) + #expect(manager.root.leafCount == 1) + } } diff --git a/PineTests/TabDragInfoTests.swift b/PineTests/TabDragInfoTests.swift index 22cdabb..ecf6d09 100644 --- a/PineTests/TabDragInfoTests.swift +++ b/PineTests/TabDragInfoTests.swift @@ -79,6 +79,64 @@ struct TabDragInfoTests { let utType = UTType.paneTabDrag #expect(utType.identifier == "com.pine.pane-tab-drag") } + + // MARK: - Additional encoding scenarios + + @Test func encode_withUnicodePathCharacters_roundtrips() { + let url = URL(fileURLWithPath: "/tmp/unicode-chars.swift") + let info = TabDragInfo(paneID: UUID(), tabID: UUID(), fileURL: url) + let decoded = TabDragInfo.decode(from: info.encoded) + #expect(decoded != nil) + #expect(decoded?.fileURL == url) + } + + @Test func encode_withDeepNestedPath_roundtrips() { + let url = URL(fileURLWithPath: "/Users/test/Documents/projects/my-app/Sources/Models/very/deep/path/file.swift") + let info = TabDragInfo(paneID: UUID(), tabID: UUID(), fileURL: url) + let decoded = TabDragInfo.decode(from: info.encoded) + #expect(decoded != nil) + #expect(decoded?.fileURL == url) + } + + @Test func decode_partialJSON_returnsNil() { + let json = #"{"paneID":"00000000-0000-0000-0000-000000000000"}"# + #expect(TabDragInfo.decode(from: json) == nil) + } + + @Test func decode_extraFields_succeeds() { + let paneUUID = UUID() + let tabUUID = UUID() + let url = URL(fileURLWithPath: "/tmp/test.swift") + let info = TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url) + let encoded = info.encoded + let modified = encoded.replacingOccurrences(of: "}", with: #","extra":"value"}"#) + let decoded = TabDragInfo.decode(from: modified) + #expect(decoded != nil) + #expect(decoded?.paneID == paneUUID) + } + + @Test func encode_multipleInstances_produceDifferentJSON() { + let info1 = TabDragInfo(paneID: UUID(), tabID: UUID(), fileURL: URL(fileURLWithPath: "/a.swift")) + let info2 = TabDragInfo(paneID: UUID(), tabID: UUID(), fileURL: URL(fileURLWithPath: "/b.swift")) + #expect(info1.encoded != info2.encoded) + } + + @Test func decode_nullString_returnsNil() { + #expect(TabDragInfo.decode(from: "null") == nil) + } + + @Test func decode_arrayJSON_returnsNil() { + #expect(TabDragInfo.decode(from: "[]") == nil) + } + + @Test func encode_preservesAllUUIDValues() { + let paneUUID = UUID() + let tabUUID = UUID() + let url = URL(fileURLWithPath: "/tmp/test.swift") + let info = TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url) + let encoded = info.encoded + #expect(encoded.contains(paneUUID.uuidString.uppercased()) || encoded.contains(paneUUID.uuidString.lowercased())) + } } @Suite("PaneDropZone Tests") @@ -95,4 +153,54 @@ struct PaneDropZoneTests { #expect(PaneDropZone.right != PaneDropZone.center) #expect(PaneDropZone.bottom != PaneDropZone.center) } + + @Test func allZones_areDifferent() { + let zones: [PaneDropZone] = [.right, .bottom, .center] + for zoneIdx in 0.. Date: Tue, 31 Mar 2026 06:42:17 +0300 Subject: [PATCH 4/7] fix: address critical code review issues in split panes DnD (#645) - Use custom UTType .paneTabDrag instead of .text in SinglePaneSplitDropDelegate to prevent external text drops from breaking the app - Add onDisappear cleanup for NSCursor push/pop in PaneDividerView to prevent cursor leaks when SwiftUI deallocates the view without hover .ended phase - Replace magic pixel numbers (300/200) with percentage-based thresholds (0.7) in SinglePaneSplitDropDelegate, unified with PaneSplitDropDelegate via shared PaneDropZone.zone(for:in:) static method - Fix moveTab to preserve all EditorTab state (cursorPosition, scrollOffset, foldState, isPinned, encoding, etc.) via new EditorTab.reidentified(from:) - Remove duplicated PaneEditorTabBar, reuse EditorTabBar with overridePaneID - Replace trivial PaneDropZone.Equatable tests with meaningful tests for drop zone calculation, EditorTab.reidentified, and tab state preservation --- Pine/EditorAreaView.swift | 38 ++-- Pine/EditorTab.swift | 26 +++ Pine/EditorTabBar.swift | 4 +- Pine/PaneManager.swift | 20 +-- Pine/PaneTreeView.swift | 140 ++++----------- PineTests/PaneManagerTests.swift | 24 ++- PineTests/TabDragInfoTests.swift | 293 +++++++++++++++++++++++++++---- 7 files changed, 372 insertions(+), 173 deletions(-) diff --git a/Pine/EditorAreaView.swift b/Pine/EditorAreaView.swift index b4b7e40..c7e4399 100644 --- a/Pine/EditorAreaView.swift +++ b/Pine/EditorAreaView.swift @@ -34,6 +34,7 @@ struct EditorAreaView: View { @Environment(\.openWindow) private var openWindow @State private var dropZone: PaneDropZone? + @State private var viewSize: CGSize = .zero @State private var configValidator = ConfigValidator() @@ -112,12 +113,20 @@ struct EditorAreaView: View { .allowsHitTesting(false) } } + .overlay { + GeometryReader { geometry in + Color.clear + .preference(key: EditorAreaSizeKey.self, value: geometry.size) + } + } + .onPreferenceChange(EditorAreaSizeKey.self) { viewSize = $0 } .overlay { PaneDropOverlay(dropZone: dropZone) } - .onDrop(of: [.text], delegate: SinglePaneSplitDropDelegate( + .onDrop(of: [.paneTabDrag], delegate: SinglePaneSplitDropDelegate( paneManager: paneManager, - dropZone: $dropZone + dropZone: $dropZone, + viewSize: viewSize )) } @@ -202,14 +211,24 @@ struct EditorAreaView: View { // MARK: - Single Pane Split Drop Delegate +/// Preference key for tracking the editor area size. +private struct EditorAreaSizeKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} + /// Handles tab drops on the single-pane editor area to initiate a split. /// Only activates when a pane tab drag (not a file drop) enters the editor. struct SinglePaneSplitDropDelegate: DropDelegate { let paneManager: PaneManager @Binding var dropZone: PaneDropZone? + /// Actual view size from GeometryReader, used for percentage-based drop zone detection. + let viewSize: CGSize func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [.text]) + info.hasItemsConforming(to: [.paneTabDrag]) } func dropEntered(info: DropInfo) { @@ -229,10 +248,10 @@ struct SinglePaneSplitDropDelegate: DropDelegate { guard let zone = dropZone else { return false } dropZone = nil - let providers = info.itemProviders(for: [.text]) + let providers = info.itemProviders(for: [.paneTabDrag]) guard let provider = providers.first else { return false } - provider.loadItem(forTypeIdentifier: "public.text", options: nil) { data, _ in + provider.loadItem(forTypeIdentifier: UTType.paneTabDrag.identifier, options: nil) { data, _ in guard let data = data as? Data, let string = String(data: data, encoding: .utf8), let dragInfo = TabDragInfo.decode(from: string) else { return } @@ -265,13 +284,6 @@ struct SinglePaneSplitDropDelegate: DropDelegate { } private func updateDropZone(info: DropInfo) { - let location = info.location - if location.x > 300 && location.x > location.y * 1.2 { - dropZone = .right - } else if location.y > 200 && location.y > location.x * 0.8 { - dropZone = .bottom - } else { - dropZone = .center - } + dropZone = PaneDropZone.zone(for: info.location, in: viewSize) } } diff --git a/Pine/EditorTab.swift b/Pine/EditorTab.swift index 4ff8be9..f856e76 100644 --- a/Pine/EditorTab.swift +++ b/Pine/EditorTab.swift @@ -97,6 +97,32 @@ struct EditorTab: Identifiable, Hashable { self.kind = kind } + /// Creates a copy of a tab with a fresh UUID, preserving all content and editor state. + /// Used when moving tabs between panes to avoid identity collisions. + static func reidentified(from source: EditorTab) -> EditorTab { + var copy = EditorTab( + url: source.url, + content: source.content, + savedContent: source.savedContent, + kind: source.kind + ) + copy.cursorPosition = source.cursorPosition + copy.scrollOffset = source.scrollOffset + copy.cursorLine = source.cursorLine + copy.cursorColumn = source.cursorColumn + copy.fileSizeBytes = source.fileSizeBytes + copy.lastModDate = source.lastModDate + copy.foldState = source.foldState + copy.previewMode = source.previewMode + copy.syntaxHighlightingDisabled = source.syntaxHighlightingDisabled + copy.isTruncated = source.isTruncated + copy.isPinned = source.isPinned + copy.cachedHighlightResult = source.cachedHighlightResult + copy.encoding = source.encoding + copy.recomputeContentCaches() + return copy + } + // Hashable by id only — content/state changes shouldn't affect identity. static func == (lhs: EditorTab, rhs: EditorTab) -> Bool { lhs.id == rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } diff --git a/Pine/EditorTabBar.swift b/Pine/EditorTabBar.swift index 1a44ce1..0b19a2f 100644 --- a/Pine/EditorTabBar.swift +++ b/Pine/EditorTabBar.swift @@ -32,6 +32,8 @@ struct EditorTabBar: View { var isAutoSaving: Bool = false /// Project root URL for computing relative paths. var projectRootURL: URL? + /// Optional pane ID override for drag operations. When nil, uses the active pane. + var overridePaneID: PaneID? /// Computes the relative path of a file URL relative to a project root URL. /// Normalizes both paths via `standardizedFileURL` to handle trailing slashes @@ -149,7 +151,7 @@ struct EditorTabBar: View { .id(tab.id) .onDrag { draggingTabID = tab.id - let paneID = paneManager.activePaneID + let paneID = overridePaneID ?? paneManager.activePaneID let info = TabDragInfo( paneID: paneID.id, tabID: tab.id, diff --git a/Pine/PaneManager.swift b/Pine/PaneManager.swift index 4fdcc43..1b12b9a 100644 --- a/Pine/PaneManager.swift +++ b/Pine/PaneManager.swift @@ -126,18 +126,14 @@ final class PaneManager { // MARK: - Private helpers private func moveTab(url: URL, from source: TabManager, to destination: TabManager) { - guard let tab = source.tabs.first(where: { $0.url == url }) else { return } - // Open in destination first (preserves content) - destination.openTab(url: url) - // Copy content to the new tab - if let destTab = destination.tabs.first(where: { $0.url == url }) { - let destIndex = destination.tabs.firstIndex(of: destTab) - if let idx = destIndex { - destination.tabs[idx].content = tab.content - destination.tabs[idx].savedContent = tab.savedContent - } - } - // Close in source + guard let srcIdx = source.tabs.firstIndex(where: { $0.url == url }) else { return } + // Take a copy of the full tab with all state + let tab = source.tabs[srcIdx] + // Close in source first (force: skip dirty check — we're moving, not discarding) source.closeTab(id: tab.id, force: true) + // Re-mint identity so the tab is fresh in the destination + let movedTab = EditorTab.reidentified(from: tab) + destination.tabs.append(movedTab) + destination.activeTabID = movedTab.id } } diff --git a/Pine/PaneTreeView.swift b/Pine/PaneTreeView.swift index d761699..4809641 100644 --- a/Pine/PaneTreeView.swift +++ b/Pine/PaneTreeView.swift @@ -161,6 +161,12 @@ struct PaneDividerView: View { NSCursor.pop() } } + .onDisappear { + if isCursorPushed { + NSCursor.pop() + isCursorPushed = false + } + } .accessibilityIdentifier(AccessibilityID.paneDivider) } } @@ -226,15 +232,15 @@ struct PaneLeafView: View { private func paneContent(tabManager: TabManager) -> some View { VStack(spacing: 0) { if !tabManager.tabs.isEmpty { - PaneEditorTabBar( + EditorTabBar( tabManager: tabManager, - paneID: paneID, onCloseTab: { tab in tabManager.closeTab(id: tab.id, force: false) if tabManager.tabs.isEmpty { paneManager.removePane(paneID) } - } + }, + overridePaneID: paneID ) } @@ -305,6 +311,28 @@ enum PaneDropZone: Equatable, Sendable { case right case bottom case center + + /// Fraction of pane width/height that triggers edge drop zones (right/bottom). + static let edgeThreshold: CGFloat = 0.7 + + /// Determines the drop zone based on cursor location within a container of the given size. + /// Uses percentage-based thresholds: right 30% = split right, bottom 30% = split down, + /// center = move to pane. + static func zone(for location: CGPoint, in size: CGSize) -> PaneDropZone { + let width = size.width + let height = size.height + + let inRightZone = width > 0 && location.x > width * edgeThreshold + let inBottomZone = height > 0 && location.y > height * edgeThreshold + + if inRightZone && (!inBottomZone || location.x / width > location.y / height) { + return .right + } else if inBottomZone { + return .bottom + } else { + return .center + } + } } /// Visual overlay that shows the drop zone indicator. @@ -358,9 +386,6 @@ struct PaneSplitDropDelegate: DropDelegate { let paneSize: CGSize @Binding var dropZone: PaneDropZone? - /// Fraction of pane width/height that triggers edge drop zones (right/bottom). - private static let edgeThreshold: CGFloat = 0.7 - func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: [.paneTabDrag]) } @@ -425,107 +450,6 @@ struct PaneSplitDropDelegate: DropDelegate { } private func updateDropZone(info: DropInfo) { - let location = info.location - let width = paneSize.width - let height = paneSize.height - - // Use percentage-based thresholds relative to actual pane size. - // Right 30% = split right, bottom 30% = split down, center = move to pane. - let inRightZone = width > 0 && location.x > width * Self.edgeThreshold - let inBottomZone = height > 0 && location.y > height * Self.edgeThreshold - - if inRightZone && (!inBottomZone || location.x / width > location.y / height) { - dropZone = .right - } else if inBottomZone { - dropZone = .bottom - } else { - dropZone = .center - } - } -} - -// MARK: - Pane Editor Tab Bar - -/// A tab bar for a pane that supports dragging tabs to other panes. -struct PaneEditorTabBar: View { - var tabManager: TabManager - let paneID: PaneID - var onCloseTab: (EditorTab) -> Void - - @State private var draggingTabID: UUID? - @State private var hoverTargetTabID: UUID? - - var body: some View { - HStack(alignment: .center, spacing: 0) { - GeometryReader { geometry in - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 2) { - let pinnedCount = tabManager.pinnedTabCount - let inactiveWidth = EditorTabBar.inactiveTabWidth( - availableWidth: geometry.size.width, - tabCount: tabManager.tabs.count, - pinnedCount: pinnedCount - ) - ForEach(tabManager.tabs) { tab in - let isActive = tab.id == tabManager.activeTabID - let isDragged = tab.id == draggingTabID - EditorTabItem( - tab: tab, - isActive: isActive, - onSelect: { tabManager.activeTabID = tab.id }, - onClose: { onCloseTab(tab) }, - onTogglePin: { tabManager.togglePin(id: tab.id) }, - constrainedWidth: tab.isPinned - ? EditorTabBar.pinnedTabWidth - : isActive ? EditorTabBar.maxTabWidth : inactiveWidth - ) - .opacity(isDragged ? 0.4 : 1.0) - .scaleEffect(isDragged ? 0.95 : 1.0) - .transaction { $0.animation = nil } - .id(tab.id) - .onDrag { - draggingTabID = tab.id - let info = TabDragInfo( - paneID: paneID.id, - tabID: tab.id, - fileURL: tab.url - ) - let provider = NSItemProvider() - provider.registerDataRepresentation( - forTypeIdentifier: UTType.paneTabDrag.identifier, - visibility: .ownProcess - ) { completion in - let data = info.encoded.data(using: .utf8) ?? Data() - completion(data, nil) - return nil - } - return provider - } - .onDrop(of: [.paneTabDrag], delegate: TabDropDelegate( - tabManager: tabManager, - targetTabID: tab.id, - draggingTabID: $draggingTabID, - hoverTargetTabID: $hoverTargetTabID, - onReorder: nil - )) - } - } - .padding(.leading, 4) - .padding(.trailing, 8) - } - .frame(maxHeight: .infinity, alignment: .center) - .onChange(of: tabManager.activeTabID) { - guard let activeID = tabManager.activeTabID else { return } - withAnimation(PineAnimation.quick) { - proxy.scrollTo(activeID, anchor: .center) - } - } - } - } - } - .frame(height: LayoutMetrics.tabBarHeight) - .background(.bar) - .accessibilityIdentifier(AccessibilityID.editorTabBar) + dropZone = PaneDropZone.zone(for: info.location, in: paneSize) } } diff --git a/PineTests/PaneManagerTests.swift b/PineTests/PaneManagerTests.swift index 2ea7dea..976038e 100644 --- a/PineTests/PaneManagerTests.swift +++ b/PineTests/PaneManagerTests.swift @@ -368,7 +368,7 @@ struct PaneManagerTests { #expect(manager.tabManager(for: secondPane)?.tabs.isEmpty == true) } - @MainActor @Test func moveTabBetweenPanes_preservesTabContent() { + @MainActor @Test func moveTabBetweenPanes_preservesAllTabState() { let manager = PaneManager() let firstPane = manager.activePaneID let testURL = URL(fileURLWithPath: "/tmp/test.swift") @@ -377,6 +377,19 @@ struct PaneManagerTests { let testContent = "func hello() { print(\"world\") }" manager.tabManager(for: firstPane)?.updateContent(testContent) + // Set various tab state properties + if let srcTM = manager.tabManager(for: firstPane), + let idx = srcTM.tabs.firstIndex(where: { $0.url == testURL }) { + srcTM.tabs[idx].cursorPosition = 42 + srcTM.tabs[idx].scrollOffset = 123.5 + srcTM.tabs[idx].cursorLine = 3 + srcTM.tabs[idx].cursorColumn = 7 + srcTM.tabs[idx].isPinned = true + srcTM.tabs[idx].foldState.toggle(FoldableRange(startLine: 1, endLine: 5, startCharIndex: 0, endCharIndex: 10, kind: .braces)) + srcTM.tabs[idx].syntaxHighlightingDisabled = true + srcTM.tabs[idx].encoding = .utf16 + } + let anotherURL = URL(fileURLWithPath: "/tmp/another.swift") manager.tabManager(for: firstPane)?.openTab(url: anotherURL) @@ -388,6 +401,15 @@ struct PaneManagerTests { manager.moveTabBetweenPanes(tabURL: testURL, from: firstPane, to: secondPane) let destTab = manager.tabManager(for: secondPane)?.tabs.first(where: { $0.url == testURL }) #expect(destTab != nil) + #expect(destTab?.content == testContent) + #expect(destTab?.cursorPosition == 42) + #expect(destTab?.scrollOffset == 123.5) + #expect(destTab?.cursorLine == 3) + #expect(destTab?.cursorColumn == 7) + #expect(destTab?.isPinned == true) + #expect(destTab?.foldState.isLineHidden(2) == true) + #expect(destTab?.syntaxHighlightingDisabled == true) + #expect(destTab?.encoding == .utf16) } // MARK: - Remove pane edge cases diff --git a/PineTests/TabDragInfoTests.swift b/PineTests/TabDragInfoTests.swift index ecf6d09..9f2cd4c 100644 --- a/PineTests/TabDragInfoTests.swift +++ b/PineTests/TabDragInfoTests.swift @@ -5,6 +5,7 @@ import Testing import Foundation +import CoreGraphics import UniformTypeIdentifiers @testable import Pine @@ -154,53 +155,269 @@ struct PaneDropZoneTests { #expect(PaneDropZone.bottom != PaneDropZone.center) } - @Test func allZones_areDifferent() { - let zones: [PaneDropZone] = [.right, .bottom, .center] - for zoneIdx in 0.. 1000 * 0.7 = 700 → right zone + let location = CGPoint(x: 800, y: 200) + #expect(PaneDropZone.zone(for: location, in: size) == .right) } - @Test func zone_center_equalsItself() { - let zone: PaneDropZone = .center - #expect(zone == .center) - #expect(zone != .right) - #expect(zone != .bottom) + @Test func bottomZone_inBottomEdge() { + // y > 800 * 0.7 = 560 → bottom zone + let location = CGPoint(x: 200, y: 700) + #expect(PaneDropZone.zone(for: location, in: size) == .bottom) } - @Test func zone_sendable_conformance() { - let zone: PaneDropZone = .right - let sendableZone: any Sendable = zone - #expect(sendableZone is PaneDropZone) + @Test func rightZone_winsWhenBothEdges_rightDominant() { + // Both in right and bottom zone, but x/width > y/height → right wins + let location = CGPoint(x: 900, y: 600) + // 900/1000 = 0.9, 600/800 = 0.75 → right + #expect(PaneDropZone.zone(for: location, in: size) == .right) } - @Test func zone_switchExhaustivenessCheck() { - let zones: [PaneDropZone] = [.right, .bottom, .center] - for zone in zones { - switch zone { - case .right: - #expect(zone == .right) - case .bottom: - #expect(zone == .bottom) - case .center: - #expect(zone == .center) - } - } + @Test func bottomZone_winsWhenBothEdges_bottomDominant() { + // Both in right and bottom zone, but y/height > x/width → bottom wins + let location = CGPoint(x: 750, y: 780) + // 750/1000 = 0.75, 780/800 = 0.975 → bottom + #expect(PaneDropZone.zone(for: location, in: size) == .bottom) + } + + @Test func centerZone_atExactThresholdBoundary() { + // x = 700 exactly (not > 700) → center + let location = CGPoint(x: 700, y: 400) + #expect(PaneDropZone.zone(for: location, in: size) == .center) + } + + @Test func centerZone_zeroSize() { + let location = CGPoint(x: 100, y: 100) + #expect(PaneDropZone.zone(for: location, in: .zero) == .center) + } + + @Test func centerZone_zeroWidth() { + let zeroWidthSize = CGSize(width: 0, height: 800) + let location = CGPoint(x: 100, y: 100) + #expect(PaneDropZone.zone(for: location, in: zeroWidthSize) == .center) + } + + @Test func bottomZone_zeroWidth_butYInBottom() { + let zeroWidthSize = CGSize(width: 0, height: 800) + let location = CGPoint(x: 0, y: 700) + #expect(PaneDropZone.zone(for: location, in: zeroWidthSize) == .bottom) + } + + @Test func rightZone_zeroHeight_butXInRight() { + let zeroHeightSize = CGSize(width: 1000, height: 0) + let location = CGPoint(x: 800, y: 0) + #expect(PaneDropZone.zone(for: location, in: zeroHeightSize) == .right) + } + + @Test func centerZone_topLeftCorner() { + let location = CGPoint(x: 0, y: 0) + #expect(PaneDropZone.zone(for: location, in: size) == .center) + } + + @Test func rightZone_smallView() { + // Small view: 100x100, threshold at 70 + let smallSize = CGSize(width: 100, height: 100) + let location = CGPoint(x: 80, y: 30) + #expect(PaneDropZone.zone(for: location, in: smallSize) == .right) + } + + @Test func bottomZone_smallView() { + let smallSize = CGSize(width: 100, height: 100) + let location = CGPoint(x: 30, y: 80) + #expect(PaneDropZone.zone(for: location, in: smallSize) == .bottom) + } + + @Test func edgeThreshold_isSeventyPercent() { + #expect(PaneDropZone.edgeThreshold == 0.7) + } +} + +// MARK: - EditorTab.reidentified Tests + +@Suite("EditorTab.reidentified Tests") +struct EditorTabReidentifiedTests { + + @Test func reidentified_generatesNewID() { + let original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "let x = 1", + savedContent: "let x = 1" + ) + let copy = EditorTab.reidentified(from: original) + #expect(copy.id != original.id) + } + + @Test func reidentified_preservesURL() { + let url = URL(fileURLWithPath: "/tmp/test.swift") + let original = EditorTab(url: url, content: "abc", savedContent: "abc") + let copy = EditorTab.reidentified(from: original) + #expect(copy.url == url) + } + + @Test func reidentified_preservesContent() { + let original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "modified content", + savedContent: "original content" + ) + let copy = EditorTab.reidentified(from: original) + #expect(copy.content == "modified content") + #expect(copy.savedContent == "original content") + } + + @Test func reidentified_preservesCursorPosition() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "let x = 1\nlet y = 2", + savedContent: "let x = 1\nlet y = 2" + ) + original.cursorPosition = 42 + original.cursorLine = 5 + original.cursorColumn = 10 + let copy = EditorTab.reidentified(from: original) + #expect(copy.cursorPosition == 42) + #expect(copy.cursorLine == 5) + #expect(copy.cursorColumn == 10) + } + + @Test func reidentified_preservesScrollOffset() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "test", + savedContent: "test" + ) + original.scrollOffset = 123.5 + let copy = EditorTab.reidentified(from: original) + #expect(copy.scrollOffset == 123.5) + } + + @Test func reidentified_preservesFoldState() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "test", + savedContent: "test" + ) + original.foldState.toggle(FoldableRange(startLine: 1, endLine: 5, startCharIndex: 0, endCharIndex: 10, kind: .braces)) + let copy = EditorTab.reidentified(from: original) + #expect(copy.foldState.isLineHidden(2)) + } + + @Test func reidentified_preservesIsPinned() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "test", + savedContent: "test" + ) + original.isPinned = true + let copy = EditorTab.reidentified(from: original) + #expect(copy.isPinned == true) + } + + @Test func reidentified_preservesSyntaxHighlightingDisabled() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "test", + savedContent: "test" + ) + original.syntaxHighlightingDisabled = true + let copy = EditorTab.reidentified(from: original) + #expect(copy.syntaxHighlightingDisabled == true) + } + + @Test func reidentified_preservesIsTruncated() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "test", + savedContent: "test" + ) + original.isTruncated = true + let copy = EditorTab.reidentified(from: original) + #expect(copy.isTruncated == true) + } + + @Test func reidentified_preservesEncoding() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "test", + savedContent: "test" + ) + original.encoding = .utf16 + let copy = EditorTab.reidentified(from: original) + #expect(copy.encoding == .utf16) + } + + @Test func reidentified_preservesPreviewMode() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.md"), + content: "# Hello", + savedContent: "# Hello" + ) + original.previewMode = .split + let copy = EditorTab.reidentified(from: original) + #expect(copy.previewMode == .split) + } + + @Test func reidentified_preservesFileSizeBytes() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "test", + savedContent: "test" + ) + original.fileSizeBytes = 4096 + let copy = EditorTab.reidentified(from: original) + #expect(copy.fileSizeBytes == 4096) + } + + @Test func reidentified_preservesLastModDate() { + var original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "test", + savedContent: "test" + ) + let date = Date(timeIntervalSince1970: 1_000_000) + original.lastModDate = date + let copy = EditorTab.reidentified(from: original) + #expect(copy.lastModDate == date) + } + + @Test func reidentified_preservesKind() { + let original = EditorTab( + url: URL(fileURLWithPath: "/tmp/image.png"), + content: "", + savedContent: "", + kind: .preview + ) + let copy = EditorTab.reidentified(from: original) + #expect(copy.kind == .preview) + } + + @Test func reidentified_dirtyTab_staysDirty() { + let original = EditorTab( + url: URL(fileURLWithPath: "/tmp/test.swift"), + content: "modified", + savedContent: "original" + ) + #expect(original.isDirty) + let copy = EditorTab.reidentified(from: original) + #expect(copy.isDirty) } } From 8fa849d156279878b05d0e07c6add0d0247492e3 Mon Sep 17 00:00:00 2001 From: Fedor Batonogov Date: Tue, 31 Mar 2026 07:00:29 +0300 Subject: [PATCH 5/7] fix: add dirty tab confirmation, git data, and status bar to split panes - PaneLeafView now shows dirty tab confirmation dialog on close - Connected onCloseOtherTabs/onCloseTabsToTheRight/onCloseAllTabs context menu handlers - Wired lineDiffs from GitStatusProvider for gutter diff markers - Wired blameLines from git blame for inline blame annotations - Added diffHunks/onAcceptHunk/onRevertHunk for inline diff - Added StatusBarView to each pane leaf - Removed dead PaneContent.terminal case from enum - Updated PaneNodeTests for single PaneContent case - Added PaneLeafCloseTests with 12 tests covering close logic --- Pine/PaneNode.swift | 10 +- Pine/PaneTreeView.swift | 215 ++++++++++++++++++- PineTests/PaneLeafCloseTests.swift | 320 +++++++++++++++++++++++++++++ PineTests/PaneNodeTests.swift | 176 ++++++++-------- 4 files changed, 616 insertions(+), 105 deletions(-) create mode 100644 PineTests/PaneLeafCloseTests.swift diff --git a/Pine/PaneNode.swift b/Pine/PaneNode.swift index 72b7491..eae37ce 100644 --- a/Pine/PaneNode.swift +++ b/Pine/PaneNode.swift @@ -19,17 +19,9 @@ struct PaneID: Hashable, Codable, Identifiable, Sendable { } /// The type of content a leaf pane displays. -/// -/// In Phase 2, each `PaneContent` case will be associated with a `TabManager` instance -/// that manages the tabs within that pane. The planned integration: -/// - `.editor` panes will own a `TabManager` for editor tabs (files) -/// - `.terminal` panes will own a `TabManager` for terminal sessions -/// -/// A `PaneState` wrapper (to be introduced in Phase 2) will pair `PaneContent` -/// with its `TabManager`, keyed by `PaneID` in a flat dictionary for O(1) lookup. +/// Each pane owns a `TabManager` for editor tabs, keyed by `PaneID`. enum PaneContent: String, Hashable, Codable, Sendable { case editor - case terminal } /// Split direction for a non-leaf pane. diff --git a/Pine/PaneTreeView.swift b/Pine/PaneTreeView.swift index 4809641..2b3c112 100644 --- a/Pine/PaneTreeView.swift +++ b/Pine/PaneTreeView.swift @@ -179,10 +179,14 @@ struct PaneLeafView: View { @Environment(PaneManager.self) private var paneManager @Environment(WorkspaceManager.self) private var workspace @Environment(ProjectManager.self) private var projectManager + @Environment(TerminalManager.self) private var terminal @Environment(ProjectRegistry.self) private var registry @Environment(\.openWindow) private var openWindow @State private var lineDiffs: [GitLineDiff] = [] + @State private var diffHunks: [DiffHunk] = [] + @State private var blameLines: [GitBlameLine] = [] + @State private var blameTask: Task? @State private var isDragTargeted = false @State private var goToLineOffset: GoToRequest? @State private var dropZone: PaneDropZone? @@ -224,6 +228,14 @@ struct PaneLeafView: View { : Color.clear, width: 1 ) + .onChange(of: tabManager.activeTabID) { _, _ in + refreshLineDiffs(tabManager: tabManager) + refreshBlame(tabManager: tabManager) + } + .modifier(BlameObserver( + isBlameVisible: isBlameVisible, + onRefresh: { refreshBlame(tabManager: tabManager) } + )) .accessibilityIdentifier(AccessibilityID.paneLeaf(paneID.id.uuidString)) } } @@ -235,10 +247,16 @@ struct PaneLeafView: View { EditorTabBar( tabManager: tabManager, onCloseTab: { tab in - tabManager.closeTab(id: tab.id, force: false) - if tabManager.tabs.isEmpty { - paneManager.removePane(paneID) - } + closeTabWithConfirmation(tab, tabManager: tabManager) + }, + onCloseOtherTabs: { tabID in + closeOtherTabsWithConfirmation(keeping: tabID, tabManager: tabManager) + }, + onCloseTabsToTheRight: { tabID in + closeTabsToTheRightWithConfirmation(of: tabID, tabManager: tabManager) + }, + onCloseAllTabs: { + closeAllTabsWithConfirmation(tabManager: tabManager) }, overridePaneID: paneID ) @@ -262,6 +280,13 @@ struct PaneLeafView: View { } .accessibilityIdentifier(AccessibilityID.editorPlaceholder) } + + StatusBarView( + gitProvider: workspace.gitProvider, + terminal: terminal, + tabManager: tabManager, + progress: projectManager.progress + ) } } @@ -276,8 +301,11 @@ struct PaneLeafView: View { language: tab.language, fileName: tab.fileName, lineDiffs: lineDiffs, + diffHunks: diffHunks, + onAcceptHunk: { hunk in handleGutterAccept(hunk, tabManager: tabManager) }, + onRevertHunk: { hunk in handleGutterRevert(hunk, tabManager: tabManager) }, isBlameVisible: isBlameVisible, - blameLines: [], + blameLines: blameLines, foldState: Binding( get: { tab.foldState }, set: { tabManager.updateFoldState($0) } @@ -302,6 +330,183 @@ struct PaneLeafView: View { .accessibilityIdentifier(AccessibilityID.codeEditor) .onAppear { goToLineOffset = nil } } + + // MARK: - Git diff & blame + + /// Refreshes cached line diffs and diff hunks for the active tab. + private func refreshLineDiffs(tabManager: TabManager) { + guard let tab = tabManager.activeTab else { + lineDiffs = [] + diffHunks = [] + return + } + let fileURL = tab.url + let provider = workspace.gitProvider + guard provider.isGitRepository, let repoURL = workspace.rootURL else { + lineDiffs = [] + diffHunks = [] + return + } + Task { + async let diffs = provider.diffForFileAsync(at: fileURL) + async let hunks = InlineDiffProvider.fetchHunks(for: fileURL, repoURL: repoURL) + let (resolvedDiffs, resolvedHunks) = await (diffs, hunks) + if tabManager.activeTab?.url == fileURL { + lineDiffs = resolvedDiffs + diffHunks = resolvedHunks + } + } + } + + /// Refreshes cached blame data for the active tab. + private func refreshBlame(tabManager: TabManager) { + blameTask?.cancel() + guard isBlameVisible else { + blameLines = [] + return + } + guard let tab = tabManager.activeTab else { + blameLines = [] + return + } + let fileURL = tab.url + let provider = workspace.gitProvider + guard provider.isGitRepository, let repoURL = provider.repositoryURL else { + blameLines = [] + return + } + let filePath = fileURL.path + blameTask = Task.detached { + let result = GitStatusProvider.runGit( + ["blame", "--porcelain", "--", filePath], at: repoURL + ) + guard !Task.isCancelled else { return } + let lines: [GitBlameLine] + if result.exitCode == 0, !result.output.isEmpty { + lines = GitStatusProvider.parseBlame(result.output) + } else { + lines = [] + } + guard !Task.isCancelled else { return } + await MainActor.run { + if tabManager.activeTab?.url == fileURL { + blameLines = lines + } + } + } + } + + // MARK: - Gutter accept/revert + + private func handleGutterAccept(_ hunk: DiffHunk, tabManager: TabManager) { + guard let tab = tabManager.activeTab, + let repoURL = workspace.rootURL else { return } + Task { + await InlineDiffProvider.acceptHunk(hunk, fileURL: tab.url, repoURL: repoURL) + await workspace.gitProvider.refreshAsync() + refreshLineDiffs(tabManager: tabManager) + } + } + + private func handleGutterRevert(_ hunk: DiffHunk, tabManager: TabManager) { + guard let tab = tabManager.activeTab, + let repoURL = workspace.rootURL else { return } + Task { + if let newContent = await InlineDiffProvider.revertHunk( + hunk, fileURL: tab.url, repoURL: repoURL + ) { + tabManager.updateContent(newContent) + tabManager.reloadTab(url: tab.url) + await workspace.gitProvider.refreshAsync() + refreshLineDiffs(tabManager: tabManager) + } + } + } + + // MARK: - Tab close with dirty confirmation + + /// Closes a tab with unsaved-changes protection. + private func closeTabWithConfirmation(_ tab: EditorTab, tabManager: TabManager) { + if tab.isDirty { + let alert = NSAlert() + alert.messageText = Strings.unsavedChangesTitle + alert.informativeText = Strings.unsavedChangesMessage + alert.addButton(withTitle: Strings.dialogSave) + alert.addButton(withTitle: Strings.dialogDontSave) + alert.addButton(withTitle: Strings.dialogCancel) + alert.alertStyle = .warning + + let response = alert.runModal() + switch response { + case .alertFirstButtonReturn: + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tab.id }) else { return } + guard tabManager.saveTab(at: index) else { return } + Task { await workspace.gitProvider.refreshAsync() } + tabManager.closeTab(id: tab.id) + case .alertSecondButtonReturn: + tabManager.closeTab(id: tab.id) + default: + return + } + } else { + tabManager.closeTab(id: tab.id) + } + + if tabManager.tabs.isEmpty { + paneManager.removePane(paneID) + } + } + + /// Confirms and closes all tabs except the one with the given ID. + private func closeOtherTabsWithConfirmation(keeping tabID: UUID, tabManager: TabManager) { + let dirty = tabManager.dirtyTabsForCloseOthers(keeping: tabID) + guard confirmBulkClose(dirtyTabs: dirty, tabManager: tabManager) else { return } + tabManager.closeOtherTabs(keeping: tabID, force: true) + } + + /// Confirms and closes all tabs to the right of the given tab. + private func closeTabsToTheRightWithConfirmation(of tabID: UUID, tabManager: TabManager) { + let dirty = tabManager.dirtyTabsForCloseRight(of: tabID) + guard confirmBulkClose(dirtyTabs: dirty, tabManager: tabManager) else { return } + tabManager.closeTabsToTheRight(of: tabID, force: true) + } + + /// Confirms and closes all tabs. + private func closeAllTabsWithConfirmation(tabManager: TabManager) { + let dirty = tabManager.dirtyTabsForCloseAll() + guard confirmBulkClose(dirtyTabs: dirty, tabManager: tabManager) else { return } + tabManager.closeAllTabs(force: true) + paneManager.removePane(paneID) + } + + /// Prompts the user when dirty tabs would be closed in a bulk operation. + private func confirmBulkClose(dirtyTabs: [EditorTab], tabManager: TabManager) -> Bool { + guard !dirtyTabs.isEmpty else { return true } + + let fileList = dirtyTabs.map { " \u{2022} \($0.fileName)" }.joined(separator: "\n") + let alert = NSAlert() + alert.messageText = Strings.unsavedChangesTitle + alert.informativeText = Strings.unsavedChangesListMessage(fileList) + alert.addButton(withTitle: Strings.dialogSaveAll) + alert.addButton(withTitle: Strings.dialogDontSave) + alert.addButton(withTitle: Strings.dialogCancel) + alert.alertStyle = .warning + + let response = alert.runModal() + switch response { + case .alertFirstButtonReturn: + for tab in dirtyTabs { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tab.id }) else { continue } + guard tabManager.saveTab(at: index) else { return false } + } + Task { await workspace.gitProvider.refreshAsync() } + return true + case .alertSecondButtonReturn: + return true + default: + return false + } + } } // MARK: - Drop Zones diff --git a/PineTests/PaneLeafCloseTests.swift b/PineTests/PaneLeafCloseTests.swift new file mode 100644 index 0000000..d2003e8 --- /dev/null +++ b/PineTests/PaneLeafCloseTests.swift @@ -0,0 +1,320 @@ +// +// PaneLeafCloseTests.swift +// PineTests +// +// Tests for pane leaf tab close operations including dirty tab handling, +// context menu actions (close other, close right, close all), and +// pane removal when all tabs are closed. +// + +import Testing +import Foundation +@testable import Pine + +// swiftlint:disable type_body_length + +@Suite("PaneLeaf Close Logic Tests") +struct PaneLeafCloseTests { + + // MARK: - Helpers + + /// Finds a tab ID by URL, recording a test issue if not found. + @MainActor + private func tabID(for url: URL, in tabManager: TabManager) -> UUID? { + guard let id = tabManager.tabs.first(where: { $0.url == url })?.id else { + Issue.record("Tab not found for \(url.lastPathComponent)") + return nil + } + return id + } + + // MARK: - Close tab removes pane when empty + + @MainActor @Test func closingLastTab_removesPane() { + let manager = PaneManager() + let firstPane = manager.activePaneID + let url = URL(fileURLWithPath: "/tmp/test.swift") + manager.tabManager(for: firstPane)?.openTab(url: url) + + guard let secondPane = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + let url2 = URL(fileURLWithPath: "/tmp/test2.swift") + manager.tabManager(for: secondPane)?.openTab(url: url2) + + // Close the only tab in second pane + if let tm = manager.tabManager(for: secondPane), let tab = tm.tabs.first { + tm.closeTab(id: tab.id) + } + + // After closing, manually remove empty pane (mirrors PaneLeafView behavior) + if manager.tabManager(for: secondPane)?.tabs.isEmpty == true { + manager.removePane(secondPane) + } + + #expect(manager.root.leafCount == 1) + #expect(manager.tabManagers[secondPane] == nil) + } + + // MARK: - Close other tabs + + @MainActor @Test func closeOtherTabs_keepingOne_closesRest() { + let manager = PaneManager() + let pane = manager.activePaneID + guard let tm = manager.tabManager(for: pane) else { + Issue.record("No tab manager") + return + } + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + let url3 = URL(fileURLWithPath: "/tmp/c.swift") + tm.openTab(url: url1) + tm.openTab(url: url2) + tm.openTab(url: url3) + + guard let keepID = tabID(for: url2, in: tm) else { return } + tm.closeOtherTabs(keeping: keepID, force: true) + + #expect(tm.tabs.count == 1) + #expect(tm.tabs.first?.url == url2) + } + + @MainActor @Test func closeOtherTabs_skipsDirtyWhenNotForced() { + let manager = PaneManager() + let pane = manager.activePaneID + guard let tm = manager.tabManager(for: pane) else { + Issue.record("No tab manager") + return + } + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + tm.openTab(url: url1) + tm.openTab(url: url2) + + // Make url1 dirty + tm.activeTabID = tm.tabs.first(where: { $0.url == url1 })?.id + tm.updateContent("modified content") + + guard let keepID = tabID(for: url2, in: tm) else { return } + tm.closeOtherTabs(keeping: keepID, force: false) + + // Dirty tab should remain + #expect(tm.tabs.count == 2) + } + + // MARK: - Close tabs to the right + + @MainActor @Test func closeTabsToTheRight_closesOnlyRight() { + let manager = PaneManager() + let pane = manager.activePaneID + guard let tm = manager.tabManager(for: pane) else { + Issue.record("No tab manager") + return + } + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + let url3 = URL(fileURLWithPath: "/tmp/c.swift") + tm.openTab(url: url1) + tm.openTab(url: url2) + tm.openTab(url: url3) + + guard let pivotID = tabID(for: url1, in: tm) else { return } + tm.closeTabsToTheRight(of: pivotID, force: true) + + #expect(tm.tabs.count == 1) + #expect(tm.tabs.first?.url == url1) + } + + @MainActor @Test func closeTabsToTheRight_skipsDirtyWhenNotForced() { + let manager = PaneManager() + let pane = manager.activePaneID + guard let tm = manager.tabManager(for: pane) else { + Issue.record("No tab manager") + return + } + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + let url3 = URL(fileURLWithPath: "/tmp/c.swift") + tm.openTab(url: url1) + tm.openTab(url: url2) + tm.openTab(url: url3) + + // Make url3 dirty + tm.activeTabID = tm.tabs.first(where: { $0.url == url3 })?.id + tm.updateContent("modified content") + + guard let pivotID = tabID(for: url1, in: tm) else { return } + tm.closeTabsToTheRight(of: pivotID, force: false) + + // url2 closed (clean), url3 kept (dirty) + #expect(tm.tabs.count == 2) + #expect(tm.tabs.contains(where: { $0.url == url1 })) + #expect(tm.tabs.contains(where: { $0.url == url3 })) + } + + // MARK: - Close all tabs + + @MainActor @Test func closeAllTabs_forced_closesEverything() { + let manager = PaneManager() + let pane = manager.activePaneID + guard let tm = manager.tabManager(for: pane) else { + Issue.record("No tab manager") + return + } + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + tm.openTab(url: url1) + tm.openTab(url: url2) + + tm.closeAllTabs(force: true) + #expect(tm.tabs.isEmpty) + } + + @MainActor @Test func closeAllTabs_notForced_skipsDirty() { + let manager = PaneManager() + let pane = manager.activePaneID + guard let tm = manager.tabManager(for: pane) else { + Issue.record("No tab manager") + return + } + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + tm.openTab(url: url1) + tm.openTab(url: url2) + + // Make url1 dirty + tm.activeTabID = tm.tabs.first(where: { $0.url == url1 })?.id + tm.updateContent("modified") + + tm.closeAllTabs(force: false) + + // Only the dirty tab remains + #expect(tm.tabs.count == 1) + #expect(tm.tabs.first?.url == url1) + } + + // MARK: - Dirty tab tracking for bulk close + + @MainActor @Test func dirtyTabsForCloseOthers_returnsDirtyOnly() { + let manager = PaneManager() + let pane = manager.activePaneID + guard let tm = manager.tabManager(for: pane) else { + Issue.record("No tab manager") + return + } + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + let url3 = URL(fileURLWithPath: "/tmp/c.swift") + tm.openTab(url: url1) + tm.openTab(url: url2) + tm.openTab(url: url3) + + // Make url2 dirty + tm.activeTabID = tm.tabs.first(where: { $0.url == url2 })?.id + tm.updateContent("dirty content") + + guard let keepID = tabID(for: url1, in: tm) else { return } + let dirty = tm.dirtyTabsForCloseOthers(keeping: keepID) + + #expect(dirty.count == 1) + #expect(dirty.first?.url == url2) + } + + @MainActor @Test func dirtyTabsForCloseRight_returnsDirtyToTheRight() { + let manager = PaneManager() + let pane = manager.activePaneID + guard let tm = manager.tabManager(for: pane) else { + Issue.record("No tab manager") + return + } + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + let url3 = URL(fileURLWithPath: "/tmp/c.swift") + tm.openTab(url: url1) + tm.openTab(url: url2) + tm.openTab(url: url3) + + // Make url3 dirty + tm.activeTabID = tm.tabs.first(where: { $0.url == url3 })?.id + tm.updateContent("dirty content") + + guard let pivotID = tabID(for: url1, in: tm) else { return } + let dirty = tm.dirtyTabsForCloseRight(of: pivotID) + + #expect(dirty.count == 1) + #expect(dirty.first?.url == url3) + } + + @MainActor @Test func dirtyTabsForCloseAll_returnsAllDirty() { + let manager = PaneManager() + let pane = manager.activePaneID + guard let tm = manager.tabManager(for: pane) else { + Issue.record("No tab manager") + return + } + + let url1 = URL(fileURLWithPath: "/tmp/a.swift") + let url2 = URL(fileURLWithPath: "/tmp/b.swift") + tm.openTab(url: url1) + tm.openTab(url: url2) + + // Make both dirty + tm.activeTabID = tm.tabs.first(where: { $0.url == url1 })?.id + tm.updateContent("dirty1") + tm.activeTabID = tm.tabs.first(where: { $0.url == url2 })?.id + tm.updateContent("dirty2") + + let dirty = tm.dirtyTabsForCloseAll() + #expect(dirty.count == 2) + } + + // MARK: - PaneContent has only editor + + @Test func paneContent_onlyHasEditorCase() { + let content = PaneContent.editor + #expect(content.rawValue == "editor") + + // Verify encoding/decoding + let data = try? JSONEncoder().encode(content) + #expect(data != nil) + if let data { + let decoded = try? JSONDecoder().decode(PaneContent.self, from: data) + #expect(decoded == .editor) + } + } + + // MARK: - Close all then remove pane + + @MainActor @Test func closeAllTabs_thenRemovePane_works() { + let manager = PaneManager() + let firstPane = manager.activePaneID + let url = URL(fileURLWithPath: "/tmp/test.swift") + manager.tabManager(for: firstPane)?.openTab(url: url) + + guard let secondPane = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + let url2 = URL(fileURLWithPath: "/tmp/test2.swift") + manager.tabManager(for: secondPane)?.openTab(url: url2) + + // Close all tabs in second pane and remove it + manager.tabManager(for: secondPane)?.closeAllTabs(force: true) + manager.removePane(secondPane) + + #expect(manager.root.leafCount == 1) + #expect(manager.tabManagers[secondPane] == nil) + #expect(manager.activePaneID == firstPane) + } +} + +// swiftlint:enable type_body_length diff --git a/PineTests/PaneNodeTests.swift b/PineTests/PaneNodeTests.swift index 269c7bb..0861ac3 100644 --- a/PineTests/PaneNodeTests.swift +++ b/PineTests/PaneNodeTests.swift @@ -24,7 +24,7 @@ struct PaneNodeTests { let node = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) #expect(node.leafCount == 2) @@ -41,7 +41,7 @@ struct PaneNodeTests { let outer = PaneNode.split( .horizontal, first: inner, - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.6 ) #expect(outer.leafCount == 3) @@ -59,7 +59,7 @@ struct PaneNodeTests { first: .leaf(id1, .editor), second: .split( .vertical, - first: .leaf(id2, .terminal), + first: .leaf(id2, .editor), second: .leaf(id3, .editor), ratio: 0.5 ), @@ -78,7 +78,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(id1, .editor), - second: .leaf(id2, .terminal), + second: .leaf(id2, .editor), ratio: 0.5 ) #expect(tree.firstLeafID == id1) @@ -95,7 +95,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(id, .terminal), + second: .leaf(id, .editor), ratio: 0.5 ) #expect(tree.contains(id)) @@ -105,7 +105,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) #expect(!tree.contains(PaneID())) @@ -117,11 +117,11 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(editorID, .editor), - second: .leaf(terminalID, .terminal), + second: .leaf(terminalID, .editor), ratio: 0.5 ) #expect(tree.content(for: editorID) == .editor) - #expect(tree.content(for: terminalID) == .terminal) + #expect(tree.content(for: terminalID) == .editor) } @Test func content_returnsNilForUnknownID() { @@ -136,7 +136,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(id1, .editor), - second: .split(.vertical, first: .leaf(id2, .terminal), second: .leaf(id3, .editor), ratio: 0.5), + second: .split(.vertical, first: .leaf(id2, .editor), second: .leaf(id3, .editor), ratio: 0.5), ratio: 0.5 ) let ids = tree.allIDs @@ -153,7 +153,7 @@ struct PaneNodeTests { let newID = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .horizontal, newPaneID: newID, newContent: .terminal) + let result = leaf.splitting(id, axis: .horizontal, newPaneID: newID, newContent: .editor) #expect(result != nil) #expect(result?.leafCount == 2) #expect(result?.contains(id) == true) @@ -165,14 +165,14 @@ struct PaneNodeTests { let newID = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .vertical, newPaneID: newID, newContent: .terminal) + let result = leaf.splitting(id, axis: .vertical, newPaneID: newID, newContent: .editor) #expect(result?.content(for: id) == .editor) - #expect(result?.content(for: newID) == .terminal) + #expect(result?.content(for: newID) == .editor) } @Test func splitting_unknownID_returnsNil() { let leaf = PaneNode.leaf(PaneID(), .editor) - let result = leaf.splitting(PaneID(), axis: .horizontal, newPaneID: PaneID(), newContent: .terminal) + let result = leaf.splitting(PaneID(), axis: .horizontal, newPaneID: PaneID(), newContent: .editor) #expect(result == nil) } @@ -186,12 +186,12 @@ struct PaneNodeTests { second: .leaf(targetID, .editor), ratio: 0.5 ), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) let newID = PaneID() - let result = tree.splitting(targetID, axis: .horizontal, newPaneID: newID, newContent: .terminal) + let result = tree.splitting(targetID, axis: .horizontal, newPaneID: newID, newContent: .editor) #expect(result != nil) #expect(result?.leafCount == 4) #expect(result?.contains(targetID) == true) @@ -201,7 +201,7 @@ struct PaneNodeTests { @Test func splitting_customRatio() { let id = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .terminal, ratio: 0.7) + let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .editor, ratio: 0.7) if case .split(_, _, _, let ratio) = result { #expect(ratio == 0.7) } else { @@ -214,7 +214,7 @@ struct PaneNodeTests { @Test func splitting_duplicateID_returnsNil() { let id = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .horizontal, newPaneID: id, newContent: .terminal) + let result = leaf.splitting(id, axis: .horizontal, newPaneID: id, newContent: .editor) #expect(result == nil) } @@ -224,7 +224,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(existingID, .editor), - second: .leaf(targetID, .terminal), + second: .leaf(targetID, .editor), ratio: 0.5 ) // Try to split targetID using existingID as newPaneID — should fail @@ -247,7 +247,7 @@ struct PaneNodeTests { Issue.record("Expected at least one leaf") return } - let result = node.splitting(deepLeaf, axis: .vertical, newPaneID: PaneID(), newContent: .terminal) + let result = node.splitting(deepLeaf, axis: .vertical, newPaneID: PaneID(), newContent: .editor) #expect(result == nil) } @@ -263,7 +263,7 @@ struct PaneNodeTests { Issue.record("Expected at least one leaf") return } - let result = node.splitting(shallowLeaf, axis: .vertical, newPaneID: PaneID(), newContent: .terminal) + let result = node.splitting(shallowLeaf, axis: .vertical, newPaneID: PaneID(), newContent: .editor) #expect(result != nil) } @@ -275,7 +275,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(keep, .editor), - second: .leaf(remove, .terminal), + second: .leaf(remove, .editor), ratio: 0.5 ) @@ -304,7 +304,7 @@ struct PaneNodeTests { second: .leaf(removeID, .editor), ratio: 0.5 ), - second: .leaf(otherID, .terminal), + second: .leaf(otherID, .editor), ratio: 0.5 ) @@ -320,7 +320,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) #expect(tree.removing(PaneID()) == nil) @@ -332,7 +332,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(keep, .editor), - second: .leaf(remove, .terminal), + second: .leaf(remove, .editor), ratio: 0.5 ) let result = tree.removing(remove) @@ -352,7 +352,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(id1, .editor), - second: .leaf(id2, .terminal), + second: .leaf(id2, .editor), ratio: 0.5 ) @@ -368,7 +368,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) #expect(tree.updatingRatio(for: PaneID(), ratio: 0.7) == nil) @@ -384,7 +384,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(id, .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) let result = tree.updatingRatio(for: id, ratio: 0.0) @@ -400,7 +400,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(id, .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) let result = tree.updatingRatio(for: id, ratio: 1.0) @@ -421,7 +421,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .split(.vertical, first: .leaf(idA, .editor), second: .leaf(idB, .editor), ratio: 0.5), - second: .split(.vertical, first: .leaf(idC, .terminal), second: .leaf(idD, .terminal), ratio: 0.5), + second: .split(.vertical, first: .leaf(idC, .editor), second: .leaf(idD, .editor), ratio: 0.5), ratio: 0.5 ) @@ -453,7 +453,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .split(.vertical, first: .leaf(idA, .editor), second: .leaf(idB, .editor), ratio: 0.5), - second: .split(.vertical, first: .leaf(idC, .terminal), second: .leaf(idD, .terminal), ratio: 0.5), + second: .split(.vertical, first: .leaf(idC, .editor), second: .leaf(idD, .editor), ratio: 0.5), ratio: 0.5 ) @@ -472,7 +472,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(id, .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) // Direct leaf children should return nil — use updatingRatio instead @@ -483,7 +483,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .split(.vertical, first: .leaf(PaneID(), .editor), second: .leaf(PaneID(), .editor), ratio: 0.5), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) #expect(tree.updatingRatioOfSplit(containing: PaneID(), ratio: 0.7) == nil) @@ -494,7 +494,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .split(.vertical, first: .leaf(id, .editor), second: .leaf(PaneID(), .editor), ratio: 0.5), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) let result = tree.updatingRatioOfSplit(containing: id, ratio: 0.0) @@ -515,7 +515,7 @@ struct PaneNodeTests { let leafC = PaneID() let leafR = PaneID() let innerSplit = PaneNode.split(.horizontal, first: .leaf(leafA, .editor), second: .leaf(leafC, .editor), ratio: 0.5) - let leftSplit = PaneNode.split(.vertical, first: innerSplit, second: .leaf(leafB, .terminal), ratio: 0.4) + let leftSplit = PaneNode.split(.vertical, first: innerSplit, second: .leaf(leafB, .editor), ratio: 0.4) let root = PaneNode.split(.horizontal, first: leftSplit, second: .leaf(leafR, .editor), ratio: 0.6) let result = root.updatingRatioOfSplit(containing: leafA, ratio: 0.8) @@ -543,14 +543,14 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(id, .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) - let result = tree.replacing(id, with: .leaf(newID, .terminal)) + let result = tree.replacing(id, with: .leaf(newID, .editor)) #expect(result != nil) #expect(result?.contains(newID) == true) #expect(result?.contains(id) == false) - #expect(result?.content(for: newID) == .terminal) + #expect(result?.content(for: newID) == .editor) } @Test func replacing_leafWithSplit() { @@ -558,7 +558,7 @@ struct PaneNodeTests { let newA = PaneID() let newB = PaneID() let leaf = PaneNode.leaf(id, .editor) - let replacement = PaneNode.split(.vertical, first: .leaf(newA, .editor), second: .leaf(newB, .terminal), ratio: 0.5) + let replacement = PaneNode.split(.vertical, first: .leaf(newA, .editor), second: .leaf(newB, .editor), ratio: 0.5) let result = leaf.replacing(id, with: replacement) #expect(result != nil) #expect(result?.leafCount == 2) @@ -568,7 +568,7 @@ struct PaneNodeTests { @Test func replacing_unknownID_returnsNil() { let tree = PaneNode.leaf(PaneID(), .editor) - #expect(tree.replacing(PaneID(), with: .leaf(PaneID(), .terminal)) == nil) + #expect(tree.replacing(PaneID(), with: .leaf(PaneID(), .editor)) == nil) } @Test func replacing_deepInTree() { @@ -577,10 +577,10 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .split(.vertical, first: .leaf(targetID, .editor), second: .leaf(PaneID(), .editor), ratio: 0.5), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) - let result = tree.replacing(targetID, with: .leaf(newID, .terminal)) + let result = tree.replacing(targetID, with: .leaf(newID, .editor)) #expect(result != nil) #expect(result?.contains(newID) == true) #expect(result?.contains(targetID) == false) @@ -594,13 +594,13 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(idA, .editor), - second: .leaf(idB, .terminal), + second: .leaf(idB, .editor), ratio: 0.5 ) let result = tree.swapping(idA, with: idB) #expect(result != nil) // Content should be swapped, IDs stay in place - #expect(result?.content(for: idA) == .terminal) + #expect(result?.content(for: idA) == .editor) #expect(result?.content(for: idB) == .editor) } @@ -631,12 +631,12 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .split(.vertical, first: .leaf(idA, .editor), second: .leaf(PaneID(), .editor), ratio: 0.5), - second: .split(.vertical, first: .leaf(idB, .terminal), second: .leaf(PaneID(), .terminal), ratio: 0.5), + second: .split(.vertical, first: .leaf(idB, .editor), second: .leaf(PaneID(), .editor), ratio: 0.5), ratio: 0.5 ) let result = tree.swapping(idA, with: idB) #expect(result != nil) - #expect(result?.content(for: idA) == .terminal) + #expect(result?.content(for: idA) == .editor) #expect(result?.content(for: idB) == .editor) } @@ -654,7 +654,7 @@ struct PaneNodeTests { let node = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.6 ) let data = try JSONEncoder().encode(node) @@ -670,13 +670,13 @@ struct PaneNodeTests { first: .leaf(PaneID(), .editor), second: .split( .horizontal, - first: .leaf(PaneID(), .terminal), + first: .leaf(PaneID(), .editor), second: .leaf(PaneID(), .editor), ratio: 0.3 ), ratio: 0.5 ), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.7 ) let data = try JSONEncoder().encode(node) @@ -689,7 +689,7 @@ struct PaneNodeTests { let id2 = PaneID() let original = PaneNode.split( .vertical, - first: .leaf(id1, .terminal), + first: .leaf(id1, .editor), second: .leaf(id2, .editor), ratio: 0.4 ) @@ -708,13 +708,13 @@ struct PaneNodeTests { let nodeA = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 0.5 ) let nodeB = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 0.5 + 1e-10 // within epsilon ) #expect(nodeA == nodeB) @@ -726,13 +726,13 @@ struct PaneNodeTests { let nodeA = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 0.5 ) let nodeB = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 0.500_002 // beyond epsilon ) #expect(nodeA != nodeB) @@ -744,13 +744,13 @@ struct PaneNodeTests { let nodeA = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 0.999_999 ) let nodeB = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 1.0 ) #expect(nodeA != nodeB) @@ -762,7 +762,7 @@ struct PaneNodeTests { let node = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.0 ) #expect(node.leafCount == 2) @@ -772,7 +772,7 @@ struct PaneNodeTests { let node = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 1.0 ) #expect(node.leafCount == 2) @@ -820,17 +820,18 @@ struct PaneNodeTests { #expect(allEditor) } - @Test func mixedEditorAndTerminalLeaves() { - let editorID = PaneID() - let terminalID = PaneID() + @Test func twoEditorLeaves_contentLookup() { + let leftID = PaneID() + let rightID = PaneID() let tree = PaneNode.split( .horizontal, - first: .leaf(editorID, .editor), - second: .leaf(terminalID, .terminal), + first: .leaf(leftID, .editor), + second: .leaf(rightID, .editor), ratio: 0.5 ) - #expect(tree.content(for: editorID) == .editor) - #expect(tree.content(for: terminalID) == .terminal) + #expect(tree.content(for: leftID) == .editor) + #expect(tree.content(for: rightID) == .editor) + #expect(tree.leafCount == 2) } @Test func paneID_equalityAndHashing() { @@ -853,7 +854,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) #expect(tree.splitting(PaneID(), axis: .vertical, newPaneID: PaneID(), newContent: .editor) == nil) @@ -863,7 +864,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(PaneID(), .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) #expect(tree.removing(PaneID()) == nil) @@ -889,13 +890,13 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(remove, .editor), - second: .leaf(keep, .terminal), + second: .leaf(keep, .editor), ratio: 0.5 ) let result = tree.removing(remove) if case .leaf(let id, let content) = result { #expect(id == keep) - #expect(content == .terminal) + #expect(content == .editor) } else { Issue.record("Expected leaf node after removing first child") } @@ -906,7 +907,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(id, .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) let result = tree.updatingRatio(for: id, ratio: -0.5) @@ -940,13 +941,6 @@ struct PaneNodeTests { #expect(nodeA == nodeB) } - @Test func equatable_differentContent_areNotEqual() { - let uuid = UUID() - let nodeA = PaneNode.leaf(PaneID(id: uuid), .editor) - let nodeB = PaneNode.leaf(PaneID(id: uuid), .terminal) - #expect(nodeA != nodeB) - } - @Test func equatable_differentIDs_areNotEqual() { let nodeA = PaneNode.leaf(PaneID(), .editor) let nodeB = PaneNode.leaf(PaneID(), .editor) @@ -959,13 +953,13 @@ struct PaneNodeTests { let nodeA = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 0.5 ) let nodeB = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 0.5 ) #expect(nodeA == nodeB) @@ -977,13 +971,13 @@ struct PaneNodeTests { let nodeA = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 0.3 ) let nodeB = PaneNode.split( .horizontal, first: .leaf(PaneID(id: uuid1), .editor), - second: .leaf(PaneID(id: uuid2), .terminal), + second: .leaf(PaneID(id: uuid2), .editor), ratio: 0.7 ) #expect(nodeA != nodeB) @@ -994,7 +988,7 @@ struct PaneNodeTests { @Test func splitting_preservesHorizontalAxis() { let id = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .terminal) + let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .editor) if case .split(let axis, _, _, _) = result { #expect(axis == .horizontal) } else { @@ -1005,7 +999,7 @@ struct PaneNodeTests { @Test func splitting_preservesVerticalAxis() { let id = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .vertical, newPaneID: PaneID(), newContent: .terminal) + let result = leaf.splitting(id, axis: .vertical, newPaneID: PaneID(), newContent: .editor) if case .split(let axis, _, _, _) = result { #expect(axis == .vertical) } else { @@ -1022,7 +1016,7 @@ struct PaneNodeTests { let innerSplit = PaneNode.split( .vertical, first: .leaf(innerID1, .editor), - second: .leaf(innerID2, .terminal), + second: .leaf(innerID2, .editor), ratio: 0.4 ) let tree = PaneNode.split( @@ -1058,7 +1052,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .vertical, first: innerSplit, - second: .leaf(removeID, .terminal), + second: .leaf(removeID, .editor), ratio: 0.5 ) @@ -1071,7 +1065,7 @@ struct PaneNodeTests { @Test func splitting_ratioZero_clampsToMinimum() { let id = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .terminal, ratio: 0.0) + let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .editor, ratio: 0.0) if case .split(_, _, _, let ratio) = result { #expect(ratio == 0.1) } else { @@ -1082,7 +1076,7 @@ struct PaneNodeTests { @Test func splitting_ratioOne_clampsToMaximum() { let id = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .terminal, ratio: 1.0) + let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .editor, ratio: 1.0) if case .split(_, _, _, let ratio) = result { #expect(ratio == 0.9) } else { @@ -1093,7 +1087,7 @@ struct PaneNodeTests { @Test func splitting_negativeRatio_clampsToMinimum() { let id = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .terminal, ratio: -0.5) + let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .editor, ratio: -0.5) if case .split(_, _, _, let ratio) = result { #expect(ratio == 0.1) } else { @@ -1104,7 +1098,7 @@ struct PaneNodeTests { @Test func splitting_ratioGreaterThanOne_clampsToMaximum() { let id = PaneID() let leaf = PaneNode.leaf(id, .editor) - let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .terminal, ratio: 2.5) + let result = leaf.splitting(id, axis: .horizontal, newPaneID: PaneID(), newContent: .editor, ratio: 2.5) if case .split(_, _, _, let ratio) = result { #expect(ratio == 0.9) } else { @@ -1119,7 +1113,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(targetID, .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) // Split with extreme ratio deep in tree @@ -1141,7 +1135,7 @@ struct PaneNodeTests { @Test func replacing_onSingleLeaf_unknownID_returnsNil() { let leaf = PaneNode.leaf(PaneID(), .editor) - #expect(leaf.replacing(PaneID(), with: .leaf(PaneID(), .terminal)) == nil) + #expect(leaf.replacing(PaneID(), with: .leaf(PaneID(), .editor)) == nil) } @Test func swapping_bothUnknown_returnsNil() { @@ -1160,7 +1154,7 @@ struct PaneNodeTests { let tree = PaneNode.split( .horizontal, first: .leaf(id, .editor), - second: .leaf(PaneID(), .terminal), + second: .leaf(PaneID(), .editor), ratio: 0.5 ) let result = tree.swapping(id, with: id) From f772847bca3cfe72af1c369ca40fed1ee960e1c2 Mon Sep 17 00:00:00 2001 From: Fedor Batonogov Date: Tue, 31 Mar 2026 07:17:12 +0300 Subject: [PATCH 6/7] fix: route menu commands through active pane TabManager, fix DnD and tap conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. pm.tabManager → pm.activeTabManager in all menu commands (Cmd+S/W, Save As, Duplicate, Ctrl+Tab, Cmd+1..9), CloseDelegate, and applicationShouldTerminate/WillTerminate. Multi-pane mode now correctly targets the focused pane. 2. Merged two .onDrop handlers in EditorAreaView into a single EditorAreaUnifiedDropDelegate — the second .onDrop was overriding the first, breaking file drops from Finder in single-pane mode. 3. Replaced .onTapGesture in PaneLeafView with PaneFocusDetector (NSView local event monitor) — the tap gesture was blocking clicks on the code editor text and tab bar buttons. 4. Reordered moveTab in PaneManager: add to destination first, then remove from source. Prevents tab loss if append fails. 5. Session persistence now collects tabs from ALL panes via pm.allTabs, so split-pane tabs survive save/restore cycles. 6. DocumentEditedTracker uses pm.hasUnsavedChanges (all panes) instead of single tabManager. --- Pine/ContentView.swift | 2 +- Pine/EditorAreaView.swift | 54 +++-- Pine/PaneManager.swift | 10 +- Pine/PaneTreeView.swift | 61 +++++- Pine/PineApp.swift | 97 ++++----- Pine/ProjectManager.swift | 53 ++++- PineTests/MultiPaneIntegrationTests.swift | 241 ++++++++++++++++++++++ 7 files changed, 442 insertions(+), 76 deletions(-) create mode 100644 PineTests/MultiPaneIntegrationTests.swift diff --git a/Pine/ContentView.swift b/Pine/ContentView.swift index efed46a..79ec048 100644 --- a/Pine/ContentView.swift +++ b/Pine/ContentView.swift @@ -104,7 +104,7 @@ struct ContentView: View { gitProvider: workspace.gitProvider, isGitRepository: workspace.gitProvider.isGitRepository ) - DocumentEditedTracker(isEdited: tabManager.hasUnsavedChanges) + DocumentEditedTracker(isEdited: projectManager.hasUnsavedChanges) RepresentedFileTracker(url: activeTab?.url ?? workspace.rootURL) } .task { diff --git a/Pine/EditorAreaView.swift b/Pine/EditorAreaView.swift index c7e4399..b50d0c8 100644 --- a/Pine/EditorAreaView.swift +++ b/Pine/EditorAreaView.swift @@ -102,10 +102,6 @@ struct EditorAreaView: View { .accessibilityIdentifier(AccessibilityID.editorPlaceholder) } } - .onDrop(of: [.fileURL], isTargeted: $isDragTargeted) { providers in - handleFileDrop(providers: providers) - return true - } .overlay { if isDragTargeted { RoundedRectangle(cornerRadius: 8) @@ -123,10 +119,12 @@ struct EditorAreaView: View { .overlay { PaneDropOverlay(dropZone: dropZone) } - .onDrop(of: [.paneTabDrag], delegate: SinglePaneSplitDropDelegate( + .onDrop(of: [.fileURL, .paneTabDrag], delegate: EditorAreaUnifiedDropDelegate( paneManager: paneManager, dropZone: $dropZone, - viewSize: viewSize + isDragTargeted: $isDragTargeted, + viewSize: viewSize, + onFileDrop: { providers in handleFileDrop(providers: providers) } )) } @@ -219,32 +217,60 @@ private struct EditorAreaSizeKey: PreferenceKey { } } -/// Handles tab drops on the single-pane editor area to initiate a split. -/// Only activates when a pane tab drag (not a file drop) enters the editor. -struct SinglePaneSplitDropDelegate: DropDelegate { +/// Unified drop delegate for the single-pane editor area. +/// Handles both file drops from Finder (.fileURL) and pane tab drags (.paneTabDrag) +/// in a single handler to avoid two `.onDrop` modifiers conflicting. +struct EditorAreaUnifiedDropDelegate: DropDelegate { let paneManager: PaneManager @Binding var dropZone: PaneDropZone? - /// Actual view size from GeometryReader, used for percentage-based drop zone detection. + @Binding var isDragTargeted: Bool let viewSize: CGSize + let onFileDrop: ([NSItemProvider]) -> Void func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [.paneTabDrag]) + info.hasItemsConforming(to: [.paneTabDrag]) || info.hasItemsConforming(to: [.fileURL]) } func dropEntered(info: DropInfo) { - updateDropZone(info: info) + if info.hasItemsConforming(to: [.paneTabDrag]) { + updateDropZone(info: info) + } else { + isDragTargeted = true + } } func dropUpdated(info: DropInfo) -> DropProposal? { - updateDropZone(info: info) - return DropProposal(operation: .move) + if info.hasItemsConforming(to: [.paneTabDrag]) { + updateDropZone(info: info) + } + return DropProposal(operation: info.hasItemsConforming(to: [.paneTabDrag]) ? .move : .copy) } func dropExited(info: DropInfo) { dropZone = nil + isDragTargeted = false } func performDrop(info: DropInfo) -> Bool { + isDragTargeted = false + + // Pane tab drag takes priority + if info.hasItemsConforming(to: [.paneTabDrag]) { + return handlePaneTabDrop(info: info) + } + + // File drop from Finder + if info.hasItemsConforming(to: [.fileURL]) { + onFileDrop(info.itemProviders(for: [.fileURL])) + return true + } + + return false + } + + // MARK: - Pane tab drop + + private func handlePaneTabDrop(info: DropInfo) -> Bool { guard let zone = dropZone else { return false } dropZone = nil diff --git a/Pine/PaneManager.swift b/Pine/PaneManager.swift index 1b12b9a..d38deb1 100644 --- a/Pine/PaneManager.swift +++ b/Pine/PaneManager.swift @@ -50,6 +50,11 @@ final class PaneManager { tabManagers[activePaneID] } + /// Returns all TabManagers across all panes. + var allTabManagers: [TabManager] { + Array(tabManagers.values) + } + // MARK: - Split operations /// Splits a pane by placing a new pane alongside it. @@ -129,11 +134,12 @@ final class PaneManager { guard let srcIdx = source.tabs.firstIndex(where: { $0.url == url }) else { return } // Take a copy of the full tab with all state let tab = source.tabs[srcIdx] - // Close in source first (force: skip dirty check — we're moving, not discarding) - source.closeTab(id: tab.id, force: true) // Re-mint identity so the tab is fresh in the destination let movedTab = EditorTab.reidentified(from: tab) + // Add to destination FIRST — if this crashes, the tab is still in source destination.tabs.append(movedTab) destination.activeTabID = movedTab.id + // Now safe to remove from source (force: skip dirty check — we're moving, not discarding) + source.closeTab(id: tab.id, force: true) } } diff --git a/Pine/PaneTreeView.swift b/Pine/PaneTreeView.swift index 2b3c112..a9e3e9d 100644 --- a/Pine/PaneTreeView.swift +++ b/Pine/PaneTreeView.swift @@ -203,8 +203,8 @@ struct PaneLeafView: View { if let tabManager { paneContent(tabManager: tabManager) .environment(tabManager) - .onTapGesture { - paneManager.activePaneID = paneID + .background { + PaneFocusDetector(paneID: paneID, paneManager: paneManager) } .overlay { GeometryReader { geometry in @@ -658,3 +658,60 @@ struct PaneSplitDropDelegate: DropDelegate { dropZone = PaneDropZone.zone(for: info.location, in: paneSize) } } + +// MARK: - Pane Focus Detector + +/// Detects mouse-down events on any pane and sets it as the active pane. +/// Uses `NSView.hitTest`-based approach instead of `.onTapGesture`, which +/// would block clicks on the code editor and tab bar buttons. +private struct PaneFocusDetector: NSViewRepresentable { + let paneID: PaneID + let paneManager: PaneManager + + func makeNSView(context: Context) -> PaneFocusNSView { + PaneFocusNSView(paneID: paneID, paneManager: paneManager) + } + + func updateNSView(_ nsView: PaneFocusNSView, context: Context) { + nsView.paneID = paneID + nsView.paneManager = paneManager + } +} + +/// NSView subclass that uses a local event monitor to detect mouse-down events +/// within this view's frame and set the corresponding pane as active. +final class PaneFocusNSView: NSView { + var paneID: PaneID + var paneManager: PaneManager? + private var monitor: Any? + + init(paneID: PaneID, paneManager: PaneManager) { + self.paneID = paneID + self.paneManager = paneManager + super.init(frame: .zero) + installMonitor() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + private func installMonitor() { + monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + self?.handleMouseDown(event) + return event // Always pass through — never consume + } + } + + private func handleMouseDown(_ event: NSEvent) { + guard let window = self.window, event.window === window else { return } + let locationInView = convert(event.locationInWindow, from: nil) + guard bounds.contains(locationInView) else { return } + MainActor.assumeIsolated { + paneManager?.activePaneID = paneID + } + } + + deinit { + if let monitor { NSEvent.removeMonitor(monitor) } + } +} diff --git a/Pine/PineApp.swift b/Pine/PineApp.swift index 9f61454..a735374 100644 --- a/Pine/PineApp.swift +++ b/Pine/PineApp.swift @@ -71,7 +71,7 @@ struct PineApp: App { Label(Strings.menuSymbolNavigator, systemImage: MenuIcons.symbolNavigator) } .keyboardShortcut("r", modifiers: .command) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) } // View menu — add items to the existing system View menu CommandGroup(after: .toolbar) { @@ -113,7 +113,7 @@ struct PineApp: App { Button { guard let pm = focusedProject else { return } - pm.tabManager.togglePreviewMode() + pm.activeTabManager.togglePreviewMode() } label: { Label(Strings.menuTogglePreview, systemImage: MenuIcons.togglePreview) } @@ -147,13 +147,13 @@ struct PineApp: App { Button { guard let pm = focusedProject, - let url = pm.tabManager.activeTab?.url else { return } + let url = pm.activeTabManager.activeTab?.url else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) } label: { Label(Strings.menuRevealFileInFinder, systemImage: MenuIcons.revealFileInFinder) } .keyboardShortcut("r", modifiers: [.command, .shift]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { guard let pm = focusedProject, @@ -194,7 +194,7 @@ struct PineApp: App { Label(Strings.menuSendToTerminal, systemImage: MenuIcons.sendToTerminal) } .keyboardShortcut(.return, modifiers: [.command, .shift]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) } // Edit menu: Toggle Comment, Find & Replace, Find in Project CommandGroup(after: .pasteboard) { @@ -213,7 +213,7 @@ struct PineApp: App { Label(Strings.menuFind, systemImage: MenuIcons.find) } .keyboardShortcut("f", modifiers: .command) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post(name: .findAndReplace, object: nil) @@ -221,7 +221,7 @@ struct PineApp: App { Label(Strings.menuFindAndReplace, systemImage: MenuIcons.findAndReplace) } .keyboardShortcut("f", modifiers: [.command, .option]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post(name: .findNext, object: nil) @@ -229,7 +229,7 @@ struct PineApp: App { Label(Strings.menuFindNext, systemImage: MenuIcons.nextChange) } .keyboardShortcut("g", modifiers: .command) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post(name: .findPrevious, object: nil) @@ -237,7 +237,7 @@ struct PineApp: App { Label(Strings.menuFindPrevious, systemImage: MenuIcons.previousChange) } .keyboardShortcut("g", modifiers: [.command, .shift]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post(name: .useSelectionForFind, object: nil) @@ -245,7 +245,7 @@ struct PineApp: App { Label(Strings.menuUseSelectionForFind, systemImage: MenuIcons.find) } .keyboardShortcut("e", modifiers: .command) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Divider() @@ -264,7 +264,7 @@ struct PineApp: App { Label(Strings.menuGoToLine, systemImage: MenuIcons.goToLine) } .keyboardShortcut("l", modifiers: .command) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Divider() @@ -277,7 +277,7 @@ struct PineApp: App { Label(Strings.menuNextChange, systemImage: MenuIcons.nextChange) } .keyboardShortcut(.downArrow, modifiers: [.control, .option]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post( @@ -288,7 +288,7 @@ struct PineApp: App { Label(Strings.menuPreviousChange, systemImage: MenuIcons.previousChange) } .keyboardShortcut(.upArrow, modifiers: [.control, .option]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post( @@ -299,7 +299,7 @@ struct PineApp: App { Label(Strings.menuAcceptChange, systemImage: MenuIcons.acceptChange) } .keyboardShortcut(.return, modifiers: [.control, .option]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post( @@ -310,7 +310,7 @@ struct PineApp: App { Label(Strings.menuRevertChange, systemImage: MenuIcons.revertChange) } .keyboardShortcut(.delete, modifiers: [.control, .option]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post( @@ -320,7 +320,7 @@ struct PineApp: App { } label: { Label(Strings.menuAcceptAllChanges, systemImage: MenuIcons.acceptAllChanges) } - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post( @@ -330,7 +330,7 @@ struct PineApp: App { } label: { Label(Strings.menuRevertAllChanges, systemImage: MenuIcons.revertAllChanges) } - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Divider() @@ -343,7 +343,7 @@ struct PineApp: App { Label(Strings.menuFoldCode, systemImage: MenuIcons.foldCode) } .keyboardShortcut(.leftArrow, modifiers: [.command, .option]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post( @@ -354,7 +354,7 @@ struct PineApp: App { Label(Strings.menuUnfoldCode, systemImage: MenuIcons.unfoldCode) } .keyboardShortcut(.rightArrow, modifiers: [.command, .option]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post( @@ -365,7 +365,7 @@ struct PineApp: App { Label(Strings.menuFoldAll, systemImage: MenuIcons.foldAll) } .keyboardShortcut(.leftArrow, modifiers: [.command, .option, .shift]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) Button { NotificationCenter.default.post( @@ -376,13 +376,13 @@ struct PineApp: App { Label(Strings.menuUnfoldAll, systemImage: MenuIcons.unfoldAll) } .keyboardShortcut(.rightArrow, modifiers: [.command, .option, .shift]) - .disabled(focusedProject?.tabManager.activeTab == nil) + .disabled(focusedProject?.activeTabManager.activeTab == nil) } // File menu: Save, Save All, Save As, Duplicate CommandGroup(replacing: .saveItem) { Button { guard let pm = focusedProject else { return } - if pm.tabManager.saveActiveTab() { + if pm.activeTabManager.saveActiveTab() { Task { await pm.workspace.gitProvider.refreshAsync() NotificationCenter.default.post(name: .refreshLineDiffs, object: nil) @@ -395,7 +395,7 @@ struct PineApp: App { Button { guard let pm = focusedProject else { return } - if pm.tabManager.saveAllTabs() { + if pm.saveAllPaneTabs() { Task { await pm.workspace.gitProvider.refreshAsync() NotificationCenter.default.post(name: .refreshLineDiffs, object: nil) @@ -410,16 +410,16 @@ struct PineApp: App { Button { guard let pm = focusedProject else { return } - guard pm.tabManager.activeTab != nil else { return } + guard pm.activeTabManager.activeTab != nil else { return } let panel = NSSavePanel() panel.title = Strings.saveAsPanelTitle - panel.nameFieldStringValue = pm.tabManager.activeTab?.fileName ?? "" - if let dir = pm.tabManager.activeTab?.url.deletingLastPathComponent() { + panel.nameFieldStringValue = pm.activeTabManager.activeTab?.fileName ?? "" + if let dir = pm.activeTabManager.activeTab?.url.deletingLastPathComponent() { panel.directoryURL = dir } guard panel.runModal() == .OK, let url = panel.url else { return } do { - try pm.tabManager.saveActiveTabAs(to: url) + try pm.activeTabManager.saveActiveTabAs(to: url) Task { await pm.workspace.gitProvider.refreshAsync() NotificationCenter.default.post(name: .refreshLineDiffs, object: nil) @@ -438,7 +438,7 @@ struct PineApp: App { Button { guard let pm = focusedProject else { return } - pm.tabManager.duplicateActiveTab(projectRoot: pm.workspace.rootURL) + pm.activeTabManager.duplicateActiveTab(projectRoot: pm.workspace.rootURL) } label: { Label(Strings.menuDuplicate, systemImage: MenuIcons.duplicate) } @@ -717,7 +717,8 @@ class CloseDelegate: NSObject, NSWindowDelegate { /// Closes the active tab with unsaved-changes dialog. Called by the Cmd+W event monitor. func closeActiveTab() { - guard let tab = projectManager.tabManager.activeTab else { return } + let activeTM = projectManager.activeTabManager + guard let tab = activeTM.activeTab else { return } if tab.isDirty { let alert = NSAlert() alert.messageText = Strings.unsavedChangesTitle @@ -729,17 +730,17 @@ class CloseDelegate: NSObject, NSWindowDelegate { let response = alert.runModal() switch response { case .alertFirstButtonReturn: - if let idx = projectManager.tabManager.tabs.firstIndex(where: { $0.id == tab.id }) { - guard projectManager.tabManager.saveTab(at: idx) else { return } + if let idx = activeTM.tabs.firstIndex(where: { $0.id == tab.id }) { + guard activeTM.saveTab(at: idx) else { return } } - projectManager.tabManager.closeTab(id: tab.id) + activeTM.closeTab(id: tab.id) case .alertSecondButtonReturn: - projectManager.tabManager.closeTab(id: tab.id) + activeTM.closeTab(id: tab.id) default: break } } else { - projectManager.tabManager.closeTab(id: tab.id) + activeTM.closeTab(id: tab.id) } } @@ -750,7 +751,7 @@ class CloseDelegate: NSObject, NSWindowDelegate { } // Close button → close the entire window. - let dirty = projectManager.tabManager.dirtyTabs + let dirty = projectManager.allDirtyTabs guard !dirty.isEmpty else { return true } let fileList = dirty.map { " • \($0.fileName)" }.joined(separator: "\n") @@ -765,7 +766,7 @@ class CloseDelegate: NSObject, NSWindowDelegate { let response = alert.runModal() switch response { case .alertFirstButtonReturn: - guard projectManager.tabManager.saveAllTabs() else { + guard projectManager.saveAllPaneTabs() else { return false // Save failed — abort close } return true @@ -960,7 +961,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { let closeDelegate = window.delegate as? CloseDelegate else { return event } - if closeDelegate.projectManager.tabManager.activeTab != nil { + if closeDelegate.projectManager.activeTabManager.activeTab != nil { closeDelegate.closeActiveTab() } else { window.performClose(nil) @@ -994,10 +995,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { } let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if mods == .control { - closeDelegate.projectManager.tabManager.selectNextTab() + closeDelegate.projectManager.activeTabManager.selectNextTab() return nil } else if mods == [.control, .shift] { - closeDelegate.projectManager.tabManager.selectPreviousTab() + closeDelegate.projectManager.activeTabManager.selectPreviousTab() return nil } return event @@ -1014,13 +1015,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { let closeDelegate = window.delegate as? CloseDelegate else { return event } - let tabManager = closeDelegate.projectManager.tabManager - guard !tabManager.tabs.isEmpty else { return event } + let activeTM = closeDelegate.projectManager.activeTabManager + guard !activeTM.tabs.isEmpty else { return event } if digit == "9" { - tabManager.selectLastTab() + activeTM.selectLastTab() } else if let index = digit.wholeNumberValue { - tabManager.selectTab(at: index - 1) + activeTM.selectTab(at: index - 1) } return nil // consume event } @@ -1092,7 +1093,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { // Open files: if a project window is active, add as tabs; otherwise open parent as project if !classified.files.isEmpty { if let activeProject = activeProjectManager() { - DropHandler.openFilesAsTabs(classified.files, in: activeProject.tabManager) + DropHandler.openFilesAsTabs(classified.files, in: activeProject.activeTabManager) } else if let firstFile = classified.files.first { let projectDir = firstFile.deletingLastPathComponent().resolvingSymlinksInPath() guard registry.projectManager(for: projectDir) != nil else { return } @@ -1101,7 +1102,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { // Open files after project initializes DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in guard let pm = self?.registry.openProjects[projectDir] else { return } - DropHandler.openFilesAsTabs(classified.files, in: pm.tabManager) + DropHandler.openFilesAsTabs(classified.files, in: pm.activeTabManager) } } } @@ -1160,7 +1161,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { pm.saveSession() pm.cleanupEditorContext() // Clean up recovery files if all tabs are saved - if !pm.tabManager.hasUnsavedChanges { + if !pm.hasUnsavedChanges { pm.recoveryManager?.deleteAllRecoveryFiles() } pm.recoveryManager?.stopPeriodicSnapshots() @@ -1173,7 +1174,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { // Check for unsaved files for (_, pm) in registry.openProjects { - let dirty = pm.tabManager.dirtyTabs + let dirty = pm.allDirtyTabs guard !dirty.isEmpty else { continue } let fileList = dirty.map { " • \($0.fileName)" }.joined(separator: "\n") @@ -1188,7 +1189,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { let response = alert.runModal() switch response { case .alertFirstButtonReturn: - guard pm.tabManager.saveAllTabs() else { + guard pm.saveAllPaneTabs() else { isTerminating = false return .terminateCancel } diff --git a/Pine/ProjectManager.swift b/Pine/ProjectManager.swift index 922689f..5ac52ce 100644 --- a/Pine/ProjectManager.swift +++ b/Pine/ProjectManager.swift @@ -14,6 +14,8 @@ import SwiftUI final class ProjectManager { let workspace = WorkspaceManager() let terminal = TerminalManager() + /// The primary TabManager (first pane). For the *focused* pane's TabManager, + /// use ``activeTabManager`` which delegates to ``PaneManager/activeTabManager``. let tabManager = TabManager() let searchProvider = ProjectSearchProvider() let quickOpenProvider = QuickOpenProvider() @@ -21,6 +23,36 @@ final class ProjectManager { let contextFileWriter = ContextFileWriter() @ObservationIgnored private(set) lazy var paneManager = PaneManager(existingTabManager: tabManager) + + /// Returns the TabManager for the currently focused pane. + /// Falls back to the primary ``tabManager`` when paneManager has a single pane. + var activeTabManager: TabManager { + paneManager.activeTabManager ?? tabManager + } + + /// Collects all tabs from every pane (for session save, dirty-tab checks, etc.). + var allTabs: [EditorTab] { + paneManager.tabManagers.values.flatMap(\.tabs) + } + + /// Whether any tab in any pane has unsaved changes. + var hasUnsavedChanges: Bool { + paneManager.tabManagers.values.contains { $0.hasUnsavedChanges } + } + + /// All dirty tabs across all panes. + var allDirtyTabs: [EditorTab] { + paneManager.tabManagers.values.flatMap(\.dirtyTabs) + } + + /// Saves all tabs across all panes. Returns false if any save fails. + @discardableResult + func saveAllPaneTabs() -> Bool { + for tabMgr in paneManager.tabManagers.values { + guard tabMgr.saveAllTabs() else { return false } + } + return true + } let toastManager = ToastManager() // nonisolated(unsafe) allows deinit to call stopPeriodicSnapshots(). // RecoveryManager is only mutated on @MainActor; deinit is the only @@ -54,7 +86,7 @@ final class ProjectManager { guard recoveryManager == nil else { return } let manager = RecoveryManager(projectURL: projectURL) manager.tabsProvider = { [weak self] in - self?.tabManager.tabs ?? [] + self?.allTabs ?? [] } tabManager.recoveryManager = manager manager.startPeriodicSnapshots() @@ -62,23 +94,26 @@ final class ProjectManager { } /// Persists current session (project + open file tabs) to UserDefaults. - /// Reads from tabManager.tabs for the authoritative tab list. + /// Collects tabs from ALL panes so split-pane tabs are not lost on restore. func saveSession() { guard let rootURL = workspace.rootURL else { return } let rootPath = rootURL.path + "/" - let openFileURLs = tabManager.tabs + // Gather tabs from all panes (not just the primary tabManager) + let everyTab = allTabs + + let openFileURLs = everyTab .map(\.url) .filter { $0.path.hasPrefix(rootPath) } // Only persist active file if it belongs to the project - let activeFileURL: URL? = if let url = tabManager.activeTab?.url, + let activeFileURL: URL? = if let url = activeTabManager.activeTab?.url, url.path.hasPrefix(rootPath) { url } else { nil } // Collect preview modes for markdown tabs that aren't in default (.source) state // and belong to the project root var previewModes: [String: String]? - let mdTabs = tabManager.tabs.filter { + let mdTabs = everyTab.filter { $0.isMarkdownFile && $0.previewMode != .source && $0.url.path.hasPrefix(rootPath) } if !mdTabs.isEmpty { @@ -89,7 +124,7 @@ final class ProjectManager { } // Collect tabs with syntax highlighting disabled (large files), scoped to project root - let disabledTabs = tabManager.tabs.filter { + let disabledTabs = everyTab.filter { $0.syntaxHighlightingDisabled && $0.url.path.hasPrefix(rootPath) } let highlightingDisabledPaths: [String]? = disabledTabs.isEmpty @@ -98,7 +133,7 @@ final class ProjectManager { // Per-tab editor state (cursor, scroll, folds) var editorStates: [String: PerTabEditorState]? - let tabsWithState = tabManager.tabs.filter { tab in + let tabsWithState = everyTab.filter { tab in tab.url.path.hasPrefix(rootPath) && tab.kind == .text } if !tabsWithState.isEmpty { @@ -109,7 +144,7 @@ final class ProjectManager { } // Pinned tabs, scoped to project root - let pinnedTabs = tabManager.tabs.filter { + let pinnedTabs = everyTab.filter { $0.isPinned && $0.url.path.hasPrefix(rootPath) } let pinnedPaths: [String]? = pinnedTabs.isEmpty @@ -180,7 +215,7 @@ final class ProjectManager { /// context file writer. Called when the active tab or cursor position changes. func updateEditorContext() { guard let rootURL = workspace.rootURL else { return } - let tab = tabManager.activeTab + let tab = activeTabManager.activeTab let relativePath = ContextFileWriter.relativePath( fileURL: tab?.url, rootURL: rootURL diff --git a/PineTests/MultiPaneIntegrationTests.swift b/PineTests/MultiPaneIntegrationTests.swift new file mode 100644 index 0000000..a29880c --- /dev/null +++ b/PineTests/MultiPaneIntegrationTests.swift @@ -0,0 +1,241 @@ +// +// MultiPaneIntegrationTests.swift +// PineTests +// +// Tests for multi-pane tab management: activeTabManager, allTabs, +// dirty tracking across panes, save-all, session persistence, +// and safe moveTab ordering. +// + +import Foundation +import Testing + +@testable import Pine + +@Suite("Multi-Pane Integration Tests") +struct MultiPaneIntegrationTests { + + // MARK: - Helpers + + private func makeTempProject() throws -> (dir: URL, files: [URL]) { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PineTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let file1 = dir.appendingPathComponent("a.swift") + let file2 = dir.appendingPathComponent("b.swift") + let file3 = dir.appendingPathComponent("c.swift") + for file in [file1, file2, file3] { + try "// \(file.lastPathComponent)".write(to: file, atomically: true, encoding: .utf8) + } + return (dir, [file1, file2, file3]) + } + + private func cleanup(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } + + // MARK: - activeTabManager tracks focus + + @MainActor @Test func activeTabManager_singlePane_returnsPrimaryTabManager() { + let pm = ProjectManager() + #expect(pm.activeTabManager === pm.tabManager) + } + + @MainActor @Test func activeTabManager_switchesBetweenPanes() { + let pm = ProjectManager() + let firstPaneID = pm.paneManager.activePaneID + let firstTM = pm.paneManager.tabManager(for: firstPaneID) + + guard let secondPaneID = pm.paneManager.splitPane(firstPaneID, axis: .horizontal) else { + Issue.record("Split failed") + return + } + let secondTM = pm.paneManager.tabManager(for: secondPaneID) + + // Active should be second pane after split + #expect(pm.activeTabManager === secondTM) + + // Switch focus back to first + pm.paneManager.activePaneID = firstPaneID + #expect(pm.activeTabManager === firstTM) + } + + // MARK: - allTabs collects from all panes + + @MainActor @Test func allTabs_collectsFromAllPanes() { + let pm = ProjectManager() + let url1 = URL(fileURLWithPath: "/tmp/test-all-tabs-1.swift") + let url2 = URL(fileURLWithPath: "/tmp/test-all-tabs-2.swift") + + let firstPaneID = pm.paneManager.activePaneID + pm.tabManager.openTab(url: url1) + + guard let secondPaneID = pm.paneManager.splitPane(firstPaneID, axis: .horizontal) else { + Issue.record("Split failed") + return + } + pm.paneManager.tabManager(for: secondPaneID)?.openTab(url: url2) + + let allURLs = pm.allTabs.map(\.url) + #expect(allURLs.contains(url1)) + #expect(allURLs.contains(url2)) + #expect(pm.allTabs.count == 2) + } + + // MARK: - hasUnsavedChanges across panes + + @MainActor @Test func hasUnsavedChanges_detectsDirtyInSecondPane() throws { + let (dir, files) = try makeTempProject() + defer { cleanup(dir) } + + let pm = ProjectManager() + let firstPaneID = pm.paneManager.activePaneID + pm.tabManager.openTab(url: files[0]) + + guard let secondPaneID = pm.paneManager.splitPane(firstPaneID, axis: .horizontal) else { + Issue.record("Split failed") + return + } + let secondTM = pm.paneManager.tabManager(for: secondPaneID)! + secondTM.openTab(url: files[1]) + secondTM.updateContent("modified content") + + #expect(pm.hasUnsavedChanges == true) + #expect(pm.allDirtyTabs.count == 1) + #expect(pm.allDirtyTabs.first?.url == files[1]) + } + + @MainActor @Test func hasUnsavedChanges_falseWhenAllClean() throws { + let (dir, files) = try makeTempProject() + defer { cleanup(dir) } + + let pm = ProjectManager() + pm.tabManager.openTab(url: files[0]) + + #expect(pm.hasUnsavedChanges == false) + #expect(pm.allDirtyTabs.isEmpty) + } + + // MARK: - saveAllPaneTabs + + @MainActor @Test func saveAllPaneTabs_savesAcrossPanes() throws { + let (dir, files) = try makeTempProject() + defer { cleanup(dir) } + + let pm = ProjectManager() + let firstPaneID = pm.paneManager.activePaneID + pm.tabManager.openTab(url: files[0]) + pm.tabManager.updateContent("// modified a.swift") + + guard let secondPaneID = pm.paneManager.splitPane(firstPaneID, axis: .horizontal) else { + Issue.record("Split failed") + return + } + let secondTM = pm.paneManager.tabManager(for: secondPaneID)! + secondTM.openTab(url: files[1]) + secondTM.updateContent("// modified b.swift") + + #expect(pm.hasUnsavedChanges == true) + + let result = pm.saveAllPaneTabs() + #expect(result == true) + #expect(pm.hasUnsavedChanges == false) + + // Verify files were written + let contentA = try String(contentsOf: files[0], encoding: .utf8) + let contentB = try String(contentsOf: files[1], encoding: .utf8) + #expect(contentA == "// modified a.swift") + #expect(contentB == "// modified b.swift") + } + + // MARK: - Session persistence collects all pane tabs + + @MainActor @Test func saveSession_includesTabsFromAllPanes() throws { + let (dir, files) = try makeTempProject() + defer { + cleanup(dir) + SessionState.clear(for: dir) + } + + let pm = ProjectManager() + pm.workspace.loadDirectory(url: dir) + + let firstPaneID = pm.paneManager.activePaneID + pm.tabManager.openTab(url: files[0]) + + guard let secondPaneID = pm.paneManager.splitPane(firstPaneID, axis: .horizontal) else { + Issue.record("Split failed") + return + } + pm.paneManager.tabManager(for: secondPaneID)?.openTab(url: files[1]) + pm.paneManager.tabManager(for: secondPaneID)?.openTab(url: files[2]) + + pm.saveSession() + + let session = SessionState.load(for: dir) + #expect(session != nil) + let savedPaths = session?.openFilePaths ?? [] + #expect(savedPaths.count == 3) + #expect(savedPaths.contains(files[0].path)) + #expect(savedPaths.contains(files[1].path)) + #expect(savedPaths.contains(files[2].path)) + } + + // MARK: - moveTab safe ordering (add first, then remove) + + @MainActor @Test func moveTab_addsToDestBeforeRemovingFromSource() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + let url = URL(fileURLWithPath: "/tmp/safe-move.swift") + manager.tabManager(for: firstPane)?.openTab(url: url) + + guard let secondPane = manager.splitPane(firstPane, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + manager.moveTabBetweenPanes(tabURL: url, from: firstPane, to: secondPane) + + // Tab must exist in destination + let destTabs = manager.tabManager(for: secondPane)?.tabs ?? [] + #expect(destTabs.contains(where: { $0.url == url })) + + // Source pane was cleaned up (empty → removed) + // Since the only tab was moved, first pane should be removed + #expect(manager.tabManagers[firstPane] == nil) + } + + // MARK: - allTabManagers + + @MainActor @Test func allTabManagers_returnsAllPaneTabManagers() { + let manager = PaneManager() + let firstPane = manager.activePaneID + + #expect(manager.allTabManagers.count == 1) + + _ = manager.splitPane(firstPane, axis: .horizontal) + #expect(manager.allTabManagers.count == 2) + } + + // MARK: - activeTabManager after pane removal + + @MainActor @Test func activeTabManager_afterRemoval_switchesToRemaining() { + let pm = ProjectManager() + let firstPaneID = pm.paneManager.activePaneID + + guard let secondPaneID = pm.paneManager.splitPane(firstPaneID, axis: .horizontal) else { + Issue.record("Split failed") + return + } + + // Active is second pane + #expect(pm.paneManager.activePaneID == secondPaneID) + + pm.paneManager.removePane(secondPaneID) + + // Should fall back to first pane + #expect(pm.activeTabManager === pm.tabManager) + } +} From 80a5498ac5f529f3cbf02f5c03fd6252f759cbe6 Mon Sep 17 00:00:00 2001 From: Fedor Batonogov Date: Tue, 31 Mar 2026 07:41:57 +0300 Subject: [PATCH 7/7] fix: remove empty pane on Cmd+W close and fix PaneFocusNSView retain cycle CloseDelegate.closeActiveTab() now removes the active pane when closing its last tab, matching PaneLeafView behavior. PaneFocusNSView.paneManager is now a weak reference to prevent retain cycles. --- Pine/PaneTreeView.swift | 2 +- Pine/PineApp.swift | 7 +++ PineTests/CloseDelegateTests.swift | 70 ++++++++++++++++++++++++++++ PineTests/PaneFocusNSViewTests.swift | 40 ++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 PineTests/PaneFocusNSViewTests.swift diff --git a/Pine/PaneTreeView.swift b/Pine/PaneTreeView.swift index a9e3e9d..6305fed 100644 --- a/Pine/PaneTreeView.swift +++ b/Pine/PaneTreeView.swift @@ -682,7 +682,7 @@ private struct PaneFocusDetector: NSViewRepresentable { /// within this view's frame and set the corresponding pane as active. final class PaneFocusNSView: NSView { var paneID: PaneID - var paneManager: PaneManager? + weak var paneManager: PaneManager? private var monitor: Any? init(paneID: PaneID, paneManager: PaneManager) { diff --git a/Pine/PineApp.swift b/Pine/PineApp.swift index a735374..5c8d9a6 100644 --- a/Pine/PineApp.swift +++ b/Pine/PineApp.swift @@ -717,6 +717,8 @@ class CloseDelegate: NSObject, NSWindowDelegate { /// Closes the active tab with unsaved-changes dialog. Called by the Cmd+W event monitor. func closeActiveTab() { + let pane = projectManager.paneManager + let activePaneID = pane.activePaneID let activeTM = projectManager.activeTabManager guard let tab = activeTM.activeTab else { return } if tab.isDirty { @@ -742,6 +744,11 @@ class CloseDelegate: NSObject, NSWindowDelegate { } else { activeTM.closeTab(id: tab.id) } + + // Remove empty pane after closing the last tab (mirrors PaneLeafView behavior) + if activeTM.tabs.isEmpty { + pane.removePane(activePaneID) + } } func windowShouldClose(_ sender: NSWindow) -> Bool { diff --git a/PineTests/CloseDelegateTests.swift b/PineTests/CloseDelegateTests.swift index eb4eb51..18d23c2 100644 --- a/PineTests/CloseDelegateTests.swift +++ b/PineTests/CloseDelegateTests.swift @@ -97,6 +97,76 @@ struct CloseDelegateTests { delegate.windowWillClose(notification) } + // MARK: - closeActiveTab removes empty pane + + @Test func closeActiveTabRemovesEmptyPane() throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + let (delegate, pm, _) = makeCloseDelegate(projectURL: dir) + + let pane = pm.paneManager + let firstPaneID = pane.activePaneID + + // Open a tab in the first pane so it stays alive + let url1 = dir.appendingPathComponent("keep.swift") + try "keep".write(to: url1, atomically: true, encoding: .utf8) + pane.tabManager(for: firstPaneID)?.openTab(url: url1) + + // Split to create a second pane + guard let secondPaneID = pane.splitPane(firstPaneID, axis: .horizontal) else { + Issue.record("Split failed") + return + } + let url2 = dir.appendingPathComponent("remove.swift") + try "remove".write(to: url2, atomically: true, encoding: .utf8) + pane.tabManager(for: secondPaneID)?.openTab(url: url2) + pane.activePaneID = secondPaneID + + #expect(pane.root.leafCount == 2) + + // Close the only tab in the active (second) pane via CloseDelegate + delegate.closeActiveTab() + + // The empty pane should have been removed + #expect(pane.root.leafCount == 1) + #expect(pane.tabManagers[secondPaneID] == nil) + } + + @Test func closeActiveTabDoesNotRemovePaneWithRemainingTabs() throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + let (delegate, pm, _) = makeCloseDelegate(projectURL: dir) + + let pane = pm.paneManager + let firstPaneID = pane.activePaneID + + // Open a tab in the first pane + let url1 = dir.appendingPathComponent("keep1.swift") + try "keep1".write(to: url1, atomically: true, encoding: .utf8) + pane.tabManager(for: firstPaneID)?.openTab(url: url1) + + // Split to create a second pane with two tabs + guard let secondPaneID = pane.splitPane(firstPaneID, axis: .horizontal) else { + Issue.record("Split failed") + return + } + let url2 = dir.appendingPathComponent("stay.swift") + try "stay".write(to: url2, atomically: true, encoding: .utf8) + let url3 = dir.appendingPathComponent("close.swift") + try "close".write(to: url3, atomically: true, encoding: .utf8) + pane.tabManager(for: secondPaneID)?.openTab(url: url2) + pane.tabManager(for: secondPaneID)?.openTab(url: url3) + pane.activePaneID = secondPaneID + + #expect(pane.root.leafCount == 2) + + // Close one tab — pane should remain since it still has a tab + delegate.closeActiveTab() + + #expect(pane.root.leafCount == 2) + #expect(pane.tabManagers[secondPaneID] != nil) + } + // MARK: - observeWindowClose @Test func observeWindowCloseRegistersNotification() throws { diff --git a/PineTests/PaneFocusNSViewTests.swift b/PineTests/PaneFocusNSViewTests.swift new file mode 100644 index 0000000..9f67dbc --- /dev/null +++ b/PineTests/PaneFocusNSViewTests.swift @@ -0,0 +1,40 @@ +// +// PaneFocusNSViewTests.swift +// PineTests +// +// Tests for PaneFocusNSView — verifies weak reference to PaneManager +// to prevent retain cycles. +// + +import Testing +import AppKit +@testable import Pine + +@Suite("PaneFocusNSView Tests") +struct PaneFocusNSViewTests { + + @MainActor @Test func paneManagerPropertyIsDeclaredWeak() { + // Verify that PaneFocusNSView.paneManager is a weak optional property + // by checking that assigning nil is accepted and the property can be nil. + let paneID = PaneID() + let paneManager = PaneManager() + let view = PaneFocusNSView(paneID: paneID, paneManager: paneManager) + + #expect(view.paneManager != nil) + + // Explicitly set to nil — only works if the property is Optional (weak vars are Optional) + view.paneManager = nil + #expect(view.paneManager == nil) + } + + @MainActor @Test func paneIDUpdatable() { + let paneManager = PaneManager() + let paneID1 = PaneID() + let paneID2 = PaneID() + let view = PaneFocusNSView(paneID: paneID1, paneManager: paneManager) + + #expect(view.paneID == paneID1) + view.paneID = paneID2 + #expect(view.paneID == paneID2) + } +}