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
147 changes: 56 additions & 91 deletions Pine/ContentView+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,46 @@ extension ContentView {
// Restore editor tabs only if PM has no tabs (fresh or after restart)
if tabManager.tabs.isEmpty {
let disabledSet = Set(session.existingHighlightingDisabledPaths ?? [])
for url in session.existingFileURLs {
let disabled = disabledSet.contains(url.path)
tabManager.openTab(url: url, syntaxHighlightingDisabled: disabled)
}

// Restore preview modes for markdown tabs
if let previewModes = session.existingPreviewModes {
for index in tabManager.tabs.indices {
let path = tabManager.tabs[index].url.path
if let rawMode = previewModes[path],
let mode = MarkdownPreviewMode(rawValue: rawMode) {
tabManager.tabs[index].previewMode = mode
let previewModes = session.existingPreviewModes
let editorStates = session.existingEditorStates
let pinnedSet = session.existingPinnedPaths

// Try to restore pane layout if available
if let layoutData = session.paneLayoutData,
let restoredNode = try? JSONDecoder().decode(PaneNode.self, from: layoutData),
let assignments = session.paneTabAssignments,
restoredNode.leafCount > 1 {
let activePaneUUID = session.activePaneID.flatMap { UUID(uuidString: $0) }
paneManager.restoreLayout(from: restoredNode, activePaneUUID: activePaneUUID)

// Populate tabs into each pane's TabManager
for (paneID, tm) in paneManager.tabManagers {
guard let paths = assignments[paneID.id.uuidString] else { continue }
for path in paths {
let url = URL(fileURLWithPath: path)
guard FileManager.default.fileExists(atPath: path) else { continue }
let disabled = disabledSet.contains(path)
tm.openTab(url: url, syntaxHighlightingDisabled: disabled)
}
Self.applyTabState(to: tm, previewModes: previewModes,
editorStates: editorStates, pinnedPaths: pinnedSet)
}
}

// Restore per-tab editor state (cursor, scroll, folds)
if let editorStates = session.existingEditorStates {
for index in tabManager.tabs.indices {
let path = tabManager.tabs[index].url.path
if let state = editorStates[path] {
state.apply(to: &tabManager.tabs[index])
}
} else {
// Single-pane restore (backwards compatible)
for url in session.existingFileURLs {
let disabled = disabledSet.contains(url.path)
tabManager.openTab(url: url, syntaxHighlightingDisabled: disabled)
}
Self.applyTabState(to: tabManager, previewModes: previewModes,
editorStates: editorStates, pinnedPaths: pinnedSet)
}

if let activeURL = session.activeFileURL,
let tab = tabManager.tab(for: activeURL) {
tabManager.activeTabID = tab.id
}

didRestoreTabs = !tabManager.tabs.isEmpty
didRestoreTabs = !projectManager.allTabs.isEmpty
}

// Restore terminal state
Expand All @@ -88,6 +96,28 @@ extension ContentView {
return didRestoreTabs
}

/// Applies preview modes, editor states, and pinned status to a TabManager's tabs.
private static func applyTabState(
to tm: TabManager,
previewModes: [String: String]?,
editorStates: [String: PerTabEditorState]?,
pinnedPaths: Set<String>?
) {
for index in tm.tabs.indices {
let path = tm.tabs[index].url.path
if let rawMode = previewModes?[path],
let mode = MarkdownPreviewMode(rawValue: rawMode) {
tm.tabs[index].previewMode = mode
}
if let state = editorStates?[path] {
state.apply(to: &tm.tabs[index])
}
if pinnedPaths?.contains(path) == true {
tm.tabs[index].isPinned = true
}
}
}

func checkForRecovery() {
guard let entries = projectManager.recoveryManager?.pendingRecoveryEntries(),
!entries.isEmpty else { return }
Expand Down Expand Up @@ -326,85 +356,20 @@ extension ContentView {

extension ContentView {

/// Shows a confirmation dialog for bulk close operations when there are dirty tabs.
/// Returns `true` if the operation should proceed (user chose Save All or Don't Save),
/// `false` if cancelled. When the user chooses Save All, all dirty tabs are saved first.
private func confirmBulkClose(dirtyTabs: [EditorTab]) -> 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:
// Save all dirty tabs; abort if any save fails
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
}
}

/// Closes all tabs except the one with the given ID, with unsaved-changes protection.
func closeOtherTabsWithConfirmation(keeping tabID: UUID) {
let dirty = tabManager.dirtyTabsForCloseOthers(keeping: tabID)
guard confirmBulkClose(dirtyTabs: dirty) else { return }
tabManager.closeOtherTabs(keeping: tabID, force: true)
TabCloseHelper.closeOtherTabs(keeping: tabID, in: tabManager, gitProvider: workspace.gitProvider)
}

/// Closes all tabs to the right of the given tab, with unsaved-changes protection.
func closeTabsToTheRightWithConfirmation(of tabID: UUID) {
let dirty = tabManager.dirtyTabsForCloseRight(of: tabID)
guard confirmBulkClose(dirtyTabs: dirty) else { return }
tabManager.closeTabsToTheRight(of: tabID, force: true)
TabCloseHelper.closeTabsToTheRight(of: tabID, in: tabManager, gitProvider: workspace.gitProvider)
}

/// Closes all tabs with unsaved-changes protection.
func closeAllTabsWithConfirmation() {
let dirty = tabManager.dirtyTabsForCloseAll()
guard confirmBulkClose(dirtyTabs: dirty) else { return }
tabManager.closeAllTabs(force: true)
TabCloseHelper.closeAllTabs(in: tabManager, gitProvider: workspace.gitProvider)
}

/// Closes a tab with unsaved-changes protection.
func closeTabWithConfirmation(_ tab: EditorTab) {
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)
}
TabCloseHelper.closeTab(tab, in: tabManager, gitProvider: workspace.gitProvider)
}

func handleExternalChanges(_ result: TabManager.ExternalChangeResult) {
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)
}
Loading
Loading