Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ jobs:
'SearchResultsView.swift',
'SidebarView.swift',
'StatusBarView.swift',
'PaneTreeView.swift',
'TerminalBarView.swift',
'TerminalSearchBar.swift',
'WelcomeView.swift',
Expand Down Expand Up @@ -286,6 +287,7 @@ jobs:
'QuickOpenView.swift',
'RecoveryDialogView.swift',
'RepresentedFileTracker.swift',
'PaneTreeView.swift',
'SearchResultsView.swift',
'SidebarView.swift',
'StatusBarView.swift',
Expand Down
5 changes: 5 additions & 0 deletions Pine/AccessibilityIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
38 changes: 22 additions & 16 deletions Pine/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -283,6 +288,7 @@ struct ContentView: View {
.environment(projectManager.workspace)
.environment(projectManager.terminal)
.environment(projectManager.tabManager)
.environment(projectManager.paneManager)
.environment(projectManager.toastManager)
.environment(registry)
}
131 changes: 127 additions & 4 deletions Pine/EditorAreaView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -97,17 +100,30 @@ struct EditorAreaView: View {
.accessibilityIdentifier(AccessibilityID.editorPlaceholder)
}
}
.onDrop(of: [.fileURL], isTargeted: $isDragTargeted) { providers in
handleFileDrop(providers: providers)
return true
}
.overlay {
if isDragTargeted {
RoundedRectangle(cornerRadius: 8)
.stroke(.blue, lineWidth: 2)
.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
Expand Down Expand Up @@ -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)
}
}
26 changes: 26 additions & 0 deletions Pine/EditorTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
23 changes: 21 additions & 2 deletions Pine/EditorTabBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?

Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions Pine/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,20 @@
<string>Alternate</string>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<!-- Pane tab drag (internal DnD) -->
<dict>
<key>UTTypeIdentifier</key>
<string>com.pine.pane-tab-drag</string>
<key>UTTypeDescription</key>
<string>Pine Pane Tab Drag</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<!-- Go -->
Expand Down
Loading
Loading