diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 96fe0ba..5543306 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -243,6 +243,7 @@ jobs:
'SearchResultsView.swift',
'SidebarView.swift',
'StatusBarView.swift',
+ 'PaneTreeView.swift',
'TerminalBarView.swift',
'TerminalSearchBar.swift',
'WelcomeView.swift',
@@ -286,6 +287,7 @@ jobs:
'QuickOpenView.swift',
'RecoveryDialogView.swift',
'RepresentedFileTracker.swift',
+ 'PaneTreeView.swift',
'SearchResultsView.swift',
'SidebarView.swift',
'StatusBarView.swift',
diff --git a/Pine/AccessibilityIdentifiers.swift b/Pine/AccessibilityIdentifiers.swift
index e9de017..6a082c4 100644
--- a/Pine/AccessibilityIdentifiers.swift
+++ b/Pine/AccessibilityIdentifiers.swift
@@ -96,6 +96,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: - Toast notifications
static let toastNotification = "toastNotification"
diff --git a/Pine/ContentView.swift b/Pine/ContentView.swift
index f5d10d0..5950bd4 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
@@ -103,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 {
@@ -243,21 +244,25 @@ struct ContentView: View {
@ViewBuilder
var editorArea: some View {
- EditorAreaView(
- lineDiffs: $lineDiffs,
- isDragTargeted: $isDragTargeted,
- goToLineOffset: $goToLineOffset,
- isBlameVisible: isBlameVisible,
- blameLines: blameLines,
- isMinimapVisible: isMinimapVisible,
- isWordWrapEnabled: isWordWrapEnabled,
- diffHunks: diffHunks,
- onCloseTab: { closeTabWithConfirmation($0) },
- onCloseOtherTabs: { closeOtherTabsWithConfirmation(keeping: $0) },
- onCloseTabsToTheRight: { closeTabsToTheRightWithConfirmation(of: $0) },
- onCloseAllTabs: { closeAllTabsWithConfirmation() },
- 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,
+ diffHunks: diffHunks,
+ onCloseTab: { closeTabWithConfirmation($0) },
+ onCloseOtherTabs: { closeOtherTabsWithConfirmation(keeping: $0) },
+ onCloseTabsToTheRight: { closeTabsToTheRightWithConfirmation(of: $0) },
+ onCloseAllTabs: { closeAllTabsWithConfirmation() },
+ onSaveSession: { projectManager.saveSession() }
+ )
+ }
}
@ViewBuilder
@@ -283,6 +288,7 @@ struct ContentView: View {
.environment(projectManager.workspace)
.environment(projectManager.terminal)
.environment(projectManager.tabManager)
+ .environment(projectManager.paneManager)
.environment(projectManager.toastManager)
.environment(registry)
}
diff --git a/Pine/EditorAreaView.swift b/Pine/EditorAreaView.swift
index caa6343..188a8dc 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
@@ -30,6 +31,8 @@ struct EditorAreaView: View {
var onSaveSession: () -> Void
@Environment(\.openWindow) private var openWindow
+ @State private var dropZone: PaneDropZone?
+ @State private var viewSize: CGSize = .zero
@State private var configValidator = ConfigValidator()
@@ -97,10 +100,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)
@@ -108,6 +107,23 @@ 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: [.fileURL, .paneTabDrag], delegate: EditorAreaUnifiedDropDelegate(
+ paneManager: paneManager,
+ dropZone: $dropZone,
+ isDragTargeted: $isDragTargeted,
+ viewSize: viewSize,
+ onFileDrop: { providers in handleFileDrop(providers: providers) }
+ ))
}
@ViewBuilder
@@ -186,3 +202,110 @@ 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()
+ }
+}
+
+/// 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?
+ @Binding var isDragTargeted: Bool
+ let viewSize: CGSize
+ let onFileDrop: ([NSItemProvider]) -> Void
+
+ func validateDrop(info: DropInfo) -> Bool {
+ info.hasItemsConforming(to: [.paneTabDrag]) || info.hasItemsConforming(to: [.fileURL])
+ }
+
+ func dropEntered(info: DropInfo) {
+ if info.hasItemsConforming(to: [.paneTabDrag]) {
+ updateDropZone(info: info)
+ } else {
+ isDragTargeted = true
+ }
+ }
+
+ func dropUpdated(info: DropInfo) -> DropProposal? {
+ 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
+
+ let providers = info.itemProviders(for: [.paneTabDrag])
+ guard let provider = providers.first else { return false }
+
+ 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 }
+
+ 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) {
+ 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 6d8c39e..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
@@ -48,6 +50,8 @@ struct EditorTabBar: View {
return fileURL.path
}
+ @Environment(PaneManager.self) private var paneManager
+
@State private var draggingTabID: UUID?
@State private var hoverTargetTabID: UUID?
@@ -147,9 +151,24 @@ struct EditorTabBar: View {
.id(tab.id)
.onDrag {
draggingTabID = tab.id
- return NSItemProvider(object: tab.id.uuidString as NSString)
+ let paneID = overridePaneID ?? paneManager.activePaneID
+ 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: [.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/PaneManager.swift b/Pine/PaneManager.swift
new file mode 100644
index 0000000..d38deb1
--- /dev/null
+++ b/Pine/PaneManager.swift
@@ -0,0 +1,145 @@
+//
+// 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]
+ }
+
+ /// 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.
+ /// 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 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]
+ // 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/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
new file mode 100644
index 0000000..da9cbce
--- /dev/null
+++ b/Pine/PaneTreeView.swift
@@ -0,0 +1,716 @@
+//
+// 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
+ @State private var isCursorPushed = 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:
+ 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()
+ }
+ }
+ .onDisappear {
+ if isCursorPushed {
+ NSCursor.pop()
+ isCursorPushed = false
+ }
+ }
+ .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(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?
+ @State private var paneSize: CGSize = .zero
+
+ @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)
+ .background {
+ PaneFocusDetector(paneID: paneID, paneManager: paneManager)
+ }
+ .overlay {
+ GeometryReader { geometry in
+ Color.clear
+ .preference(key: PaneSizePreferenceKey.self, value: geometry.size)
+ }
+ }
+ .onPreferenceChange(PaneSizePreferenceKey.self) { paneSize = $0 }
+ .overlay {
+ PaneDropOverlay(dropZone: dropZone)
+ }
+ .onDrop(of: [.paneTabDrag], delegate: PaneSplitDropDelegate(
+ paneID: paneID,
+ paneManager: paneManager,
+ paneSize: paneSize,
+ dropZone: $dropZone
+ ))
+ .border(
+ isActive && paneManager.root.leafCount > 1
+ ? Color.accentColor.opacity(0.5)
+ : 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))
+ }
+ }
+
+ @ViewBuilder
+ private func paneContent(tabManager: TabManager) -> some View {
+ VStack(spacing: 0) {
+ if !tabManager.tabs.isEmpty {
+ EditorTabBar(
+ tabManager: tabManager,
+ onCloseTab: { tab in
+ 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
+ )
+ }
+
+ 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)
+ }
+
+ StatusBarView(
+ gitProvider: workspace.gitProvider,
+ terminal: terminal,
+ tabManager: tabManager,
+ progress: projectManager.progress
+ )
+ }
+ }
+
+ @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,
+ diffHunks: diffHunks,
+ isBlameVisible: isBlameVisible,
+ blameLines: 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: - 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
+
+/// Represents where a tab can be dropped relative to a pane.
+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.
+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: - 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?
+
+ func validateDrop(info: DropInfo) -> Bool {
+ info.hasItemsConforming(to: [.paneTabDrag])
+ }
+
+ 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: [.paneTabDrag])
+ guard let provider = providers.first else { return false }
+
+ 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 }
+
+ 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) {
+ 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
+ weak var paneManager: PaneManager?
+ /// nonisolated(unsafe): accessed from deinit (nonisolated) to remove event monitor.
+ nonisolated(unsafe) 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 155bc09..5c8d9a6 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)
}
@@ -547,6 +547,7 @@ private struct ProjectWindowView: View {
.environment(pm.workspace)
.environment(pm.terminal)
.environment(pm.tabManager)
+ .environment(pm.paneManager)
.environment(pm.toastManager)
.environment(registry)
.focusedSceneValue(\.projectManager, pm)
@@ -716,7 +717,10 @@ 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 pane = projectManager.paneManager
+ let activePaneID = pane.activePaneID
+ let activeTM = projectManager.activeTabManager
+ guard let tab = activeTM.activeTab else { return }
if tab.isDirty {
let alert = NSAlert()
alert.messageText = Strings.unsavedChangesTitle
@@ -728,17 +732,22 @@ 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)
+ }
+
+ // Remove empty pane after closing the last tab (mirrors PaneLeafView behavior)
+ if activeTM.tabs.isEmpty {
+ pane.removePane(activePaneID)
}
}
@@ -749,7 +758,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")
@@ -764,7 +773,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
@@ -959,7 +968,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)
@@ -993,10 +1002,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
@@ -1013,13 +1022,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
}
@@ -1091,7 +1100,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 }
@@ -1100,7 +1109,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)
}
}
}
@@ -1159,7 +1168,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()
@@ -1172,7 +1181,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")
@@ -1187,7 +1196,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 0ef41cf..5ac52ce 100644
--- a/Pine/ProjectManager.swift
+++ b/Pine/ProjectManager.swift
@@ -14,11 +14,45 @@ 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()
let progress = ProgressTracker()
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
@@ -52,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()
@@ -60,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 {
@@ -87,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
@@ -96,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 {
@@ -107,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
@@ -178,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/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
new file mode 100644
index 0000000..03e35d5
--- /dev/null
+++ b/Pine/TabDragInfo.swift
@@ -0,0 +1,38 @@
+//
+// TabDragInfo.swift
+// Pine
+//
+// Drag data for moving tabs between split panes.
+//
+
+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.
+/// JSON-encoded for NSItemProvider transport via custom UTType.
+struct TabDragInfo: Codable, Sendable {
+ let paneID: UUID
+ let tabID: UUID
+ let fileURL: URL
+
+ /// JSON-encodes to a string for drag transfer.
+ var encoded: String {
+ guard let data = try? JSONEncoder().encode(self),
+ let string = String(data: data, encoding: .utf8) else {
+ return ""
+ }
+ return string
+ }
+
+ /// Decodes from a JSON string. Returns nil if format is invalid.
+ static func decode(from string: String) -> TabDragInfo? {
+ guard let data = string.data(using: .utf8) else { return nil }
+ return try? JSONDecoder().decode(TabDragInfo.self, from: data)
+ }
+}
diff --git a/PineTests/CloseDelegateTests.swift b/PineTests/CloseDelegateTests.swift
index 93c3e8a..773bb33 100644
--- a/PineTests/CloseDelegateTests.swift
+++ b/PineTests/CloseDelegateTests.swift
@@ -98,6 +98,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/MultiPaneIntegrationTests.swift b/PineTests/MultiPaneIntegrationTests.swift
new file mode 100644
index 0000000..c9b080c
--- /dev/null
+++ b/PineTests/MultiPaneIntegrationTests.swift
@@ -0,0 +1,248 @@
+//
+// 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")
+@MainActor
+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
+
+ @Test func activeTabManager_singlePane_returnsPrimaryTabManager() {
+ let pm = ProjectManager()
+ #expect(pm.activeTabManager === pm.tabManager)
+ }
+
+ @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
+
+ @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
+
+ @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
+ }
+ guard let secondTM = pm.paneManager.tabManager(for: secondPaneID) else {
+ Issue.record("tabManager not found for second pane")
+ return
+ }
+ 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])
+ }
+
+ @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
+
+ @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
+ }
+ guard let secondTM = pm.paneManager.tabManager(for: secondPaneID) else {
+ Issue.record("tabManager not found for second pane")
+ return
+ }
+ 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
+
+ @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)
+
+ @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
+
+ @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
+
+ @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)
+ }
+}
diff --git a/PineTests/PaneFocusNSViewTests.swift b/PineTests/PaneFocusNSViewTests.swift
new file mode 100644
index 0000000..7afa431
--- /dev/null
+++ b/PineTests/PaneFocusNSViewTests.swift
@@ -0,0 +1,41 @@
+//
+// 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")
+@MainActor
+struct PaneFocusNSViewTests {
+
+ @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)
+ }
+
+ @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)
+ }
+}
diff --git a/PineTests/PaneLeafCloseTests.swift b/PineTests/PaneLeafCloseTests.swift
new file mode 100644
index 0000000..517714a
--- /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")
+@MainActor
+struct PaneLeafCloseTests {
+
+ // MARK: - Helpers
+
+ /// Finds a tab ID by URL, recording a test issue if not found.
+ 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
+
+ @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
+
+ @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)
+ }
+
+ @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
+
+ @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)
+ }
+
+ @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
+
+ @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)
+ }
+
+ @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
+
+ @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)
+ }
+
+ @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)
+ }
+
+ @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
+
+ @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/PaneManagerTests.swift b/PineTests/PaneManagerTests.swift
new file mode 100644
index 0000000..adfd82b
--- /dev/null
+++ b/PineTests/PaneManagerTests.swift
@@ -0,0 +1,496 @@
+//
+// PaneManagerTests.swift
+// PineTests
+//
+
+import Testing
+import Foundation
+@testable import Pine
+
+@Suite("PaneManager Tests")
+@MainActor
+struct PaneManagerTests {
+
+ // MARK: - Initialization
+
+ @Test func init_createsOnePaneWithTabManager() {
+ let manager = PaneManager()
+ #expect(manager.root.leafCount == 1)
+ #expect(manager.activeTabManager != nil)
+ #expect(manager.tabManagers.count == 1)
+ }
+
+ @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
+
+ @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)
+ }
+ }
+
+ @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")
+ }
+ }
+
+ @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)
+ }
+
+ @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)
+ }
+
+ @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
+
+ @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)
+ }
+
+ @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)
+ }
+
+ @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
+
+ @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)
+ }
+
+ @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
+
+ @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")
+ }
+ }
+
+ @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
+
+ @Test func tabManager_forInvalidPaneID_returnsNil() {
+ let manager = PaneManager()
+ let fakePaneID = PaneID()
+ #expect(manager.tabManager(for: fakePaneID) == nil)
+ }
+
+ @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
+
+ @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)
+ }
+
+ // MARK: - Focus cycle
+
+ @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
+
+ @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
+
+ @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)
+ }
+
+ @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)
+ }
+
+ @Test func moveTabBetweenPanes_preservesAllTabState() {
+ 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)
+
+ // 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)
+
+ 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)
+ #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
+
+ @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)
+ }
+
+ @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
+
+ @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
+
+ @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
+
+ @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/PaneNodeTests.swift b/PineTests/PaneNodeTests.swift
index ef32b7f..a5aea53 100644
--- a/PineTests/PaneNodeTests.swift
+++ b/PineTests/PaneNodeTests.swift
@@ -25,7 +25,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)
@@ -42,7 +42,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)
@@ -60,7 +60,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
),
@@ -79,7 +79,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)
@@ -96,7 +96,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))
@@ -106,7 +106,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()))
@@ -118,11 +118,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() {
@@ -137,7 +137,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
@@ -154,7 +154,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)
@@ -166,14 +166,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)
}
@@ -187,12 +187,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)
@@ -202,7 +202,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 {
@@ -215,7 +215,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)
}
@@ -225,7 +225,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
@@ -248,7 +248,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)
}
@@ -264,7 +264,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)
}
@@ -276,7 +276,7 @@ struct PaneNodeTests {
let tree = PaneNode.split(
.horizontal,
first: .leaf(keep, .editor),
- second: .leaf(remove, .terminal),
+ second: .leaf(remove, .editor),
ratio: 0.5
)
@@ -305,7 +305,7 @@ struct PaneNodeTests {
second: .leaf(removeID, .editor),
ratio: 0.5
),
- second: .leaf(otherID, .terminal),
+ second: .leaf(otherID, .editor),
ratio: 0.5
)
@@ -321,7 +321,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)
@@ -333,7 +333,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)
@@ -353,7 +353,7 @@ struct PaneNodeTests {
let tree = PaneNode.split(
.horizontal,
first: .leaf(id1, .editor),
- second: .leaf(id2, .terminal),
+ second: .leaf(id2, .editor),
ratio: 0.5
)
@@ -369,7 +369,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)
@@ -385,7 +385,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)
@@ -401,7 +401,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)
@@ -422,7 +422,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
)
@@ -454,7 +454,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
)
@@ -473,7 +473,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
@@ -484,7 +484,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)
@@ -495,7 +495,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)
@@ -516,7 +516,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)
@@ -544,14 +544,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() {
@@ -559,7 +559,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)
@@ -569,7 +569,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() {
@@ -578,10 +578,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)
@@ -595,13 +595,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)
}
@@ -632,12 +632,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)
}
@@ -655,7 +655,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)
@@ -671,13 +671,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)
@@ -690,7 +690,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
)
@@ -709,13 +709,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)
@@ -727,13 +727,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)
@@ -745,13 +745,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)
@@ -763,7 +763,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)
@@ -773,7 +773,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)
@@ -821,17 +821,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() {
@@ -854,7 +855,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)
@@ -864,7 +865,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)
@@ -890,13 +891,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")
}
@@ -907,7 +908,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)
@@ -941,13 +942,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)
@@ -960,13 +954,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)
@@ -978,13 +972,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)
@@ -995,7 +989,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 {
@@ -1006,7 +1000,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 {
@@ -1023,7 +1017,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(
@@ -1059,7 +1053,7 @@ struct PaneNodeTests {
let tree = PaneNode.split(
.vertical,
first: innerSplit,
- second: .leaf(removeID, .terminal),
+ second: .leaf(removeID, .editor),
ratio: 0.5
)
@@ -1072,7 +1066,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 {
@@ -1083,7 +1077,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 {
@@ -1094,7 +1088,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 {
@@ -1105,7 +1099,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 {
@@ -1120,7 +1114,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
@@ -1142,7 +1136,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() {
@@ -1161,7 +1155,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)
diff --git a/PineTests/TabDragInfoTests.swift b/PineTests/TabDragInfoTests.swift
new file mode 100644
index 0000000..9f2cd4c
--- /dev/null
+++ b/PineTests/TabDragInfoTests.swift
@@ -0,0 +1,423 @@
+//
+// TabDragInfoTests.swift
+// PineTests
+//
+
+import Testing
+import Foundation
+import CoreGraphics
+import UniformTypeIdentifiers
+@testable import Pine
+
+@Suite("TabDragInfo Tests")
+struct TabDragInfoTests {
+
+ @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.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_validJSON_returnsInfo() {
+ let paneUUID = UUID()
+ let tabUUID = UUID()
+ let url = URL(fileURLWithPath: "/tmp/test.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_invalidString_returnsNil() {
+ #expect(TabDragInfo.decode(from: "invalid") == nil)
+ #expect(TabDragInfo.decode(from: "{}") == nil)
+ #expect(TabDragInfo.decode(from: "") == nil)
+ }
+
+ @Test func decode_invalidJSON_returnsNil() {
+ #expect(TabDragInfo.decode(from: "{\"paneID\": \"not-a-uuid\"}") == 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 info = TabDragInfo(paneID: paneUUID, tabID: tabUUID, fileURL: url)
+
+ 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")
+ }
+
+ // 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")
+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)
+ }
+
+ @Test func sendable_conformance() {
+ let zone: PaneDropZone = .right
+ let sendableZone: any Sendable = zone
+ #expect(sendableZone is PaneDropZone)
+ }
+}
+
+// MARK: - PaneDropZone.zone(for:in:) Tests
+
+@Suite("PaneDropZone.zone Tests")
+struct PaneDropZoneZoneTests {
+ private let size = CGSize(width: 1000, height: 800)
+
+ @Test func centerZone_inMiddleOfView() {
+ let location = CGPoint(x: 300, y: 300)
+ #expect(PaneDropZone.zone(for: location, in: size) == .center)
+ }
+
+ @Test func rightZone_inRightEdge() {
+ // x > 1000 * 0.7 = 700 → right zone
+ let location = CGPoint(x: 800, y: 200)
+ #expect(PaneDropZone.zone(for: location, in: size) == .right)
+ }
+
+ @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 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 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)
+ }
+}