Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Copy To and Duplicate Database in the sidebar and the Database menu, carrying structure, data or both to any connection. (#2487)
- Tab rows in Settings > General > Tabs, wrapping the strip instead of scrolling it. (#2438)
- Autoscrolling while dragging a tab, so a tab can be moved past the run currently on screen. (#2438)
- Move Tab to New Window on a tab's right-click menu, and by dragging a tab out of the strip. (#2438)

### Changed

Expand Down
25 changes: 22 additions & 3 deletions TablePro/Core/Services/Infrastructure/ConnectionCloseAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,35 @@ internal enum ConnectionCloseAction {

/// Pure so the case that used to fail silently is pinned by a test: a connection with no session
/// has nothing to lose, and asking about it produced an alert nobody could answer.
/// Saves in every window hosting the connection, not only the one that answered first. Each
/// coordinator can save just its own selected tab's live work, so a Save that reached one of
/// them left the other window's grid edits behind and closed over them.
private static func saveEveryWindowsWork(
across coordinators: [MainContentCoordinator],
fallback: MainContentCoordinator?
) async -> Bool {
let targets = coordinators.isEmpty ? [fallback].compactMap { $0 } : coordinators
for coordinator in targets {
guard await coordinator.commandActions?.saveSelectedTabWork() == true else { return false }
}
return !targets.isEmpty
}

internal static func decision(hasSession: Bool, hasUnsavedWork: Bool) -> Decision {
guard hasSession, hasUnsavedWork else { return .closeImmediately }
return .confirmUnsavedWork
}

internal static func close(connectionId: UUID) async {
let coordinator = WindowManager.shared.coordinator(for: connectionId)
/// Every window hosting the connection. A tab torn off into its own window keeps its live
/// grid and structure edits in that window's coordinator, and asking only the first one
/// reported the connection as safe to close over work nobody had been shown.
let coordinators = WindowManager.shared.coordinators(for: connectionId)
let coordinator = coordinators.first ?? WindowManager.shared.coordinator(for: connectionId)
let decision = decision(
hasSession: coordinator != nil,
hasUnsavedWork: coordinator?.hasAnyUnsavedWork() ?? false
hasUnsavedWork: coordinators.contains { $0.hasAnyUnsavedWork() }
|| (coordinators.isEmpty && coordinator?.hasAnyUnsavedWork() == true)
)
guard decision == .confirmUnsavedWork else {
WindowManager.shared.closeWindow(for: connectionId)
Expand All @@ -55,7 +74,7 @@ internal enum ConnectionCloseAction {
case .save:
/// Save closes too, once the save has actually landed. It used to start the save and
/// stop there, so the connection the user asked to close stayed open.
guard await coordinator?.commandActions?.saveSelectedTabWork() == true else {
guard await saveEveryWindowsWork(across: coordinators, fallback: coordinator) else {
WindowManager.shared.show(wasShowing, inWindowHosting: connectionId)
break
}
Expand Down
33 changes: 33 additions & 0 deletions TablePro/Core/Services/Infrastructure/EditorTabDetachPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// EditorTabDetachPolicy.swift
// TablePro
//

import Foundation

/// Whether a tab may be moved into a window of its own, kept apart from the window machinery so
/// the rule can be tested without one.
///
/// The rule is narrow on purpose. Detaching moves a `QueryTab`, which carries what is persisted;
/// it does not carry a coordinator's live edit state, so a tab with work that has not been written
/// yet would arrive in the new window with that work silently gone. Refusing is the honest answer,
/// and the same one the strip gives for a reorder it cannot complete: the command dims rather than
/// destroying something quietly.
internal enum EditorTabDetachPolicy {
internal static func canDetach(
tabCount: Int,
hasUnsavedWork: Bool,
isBusy: Bool,
isConnected: Bool
) -> Bool {
/// The last tab has nowhere to go. Moving it would empty this window and fill an identical
/// one, which is what `Move Connection to New Window` already does and says.
guard tabCount > 1 else { return false }
guard !hasUnsavedWork else { return false }
/// A query or a table load in flight is claimed by the coordinator that started it. Moving
/// the tab out from under one leaves the completion with no tab to write into and the new
/// window with no claim, so it fetches the same page again.
guard !isBusy else { return false }
return isConnected
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ internal extension MainSplitViewController {
/// The strip is built per connection like the other three panes, so a connection the user is
/// not looking at keeps its own strip rather than rebuilding it on every switch.
func refreshTabStripPane(of workspace: ConnectionWorkspace) {
workspace.panes.tabStrip.rootView = AnyView(buildTabStripView(for: workspace))
workspace.panes.tabStrip.rootView = buildTabStripView(for: workspace)
}

func showSelectedTabStrip() {
Expand Down Expand Up @@ -84,11 +84,11 @@ internal extension MainSplitViewController {
/// The command set is handed to the pane's interaction object rather than to the SwiftUI view,
/// because the view is no longer what receives a press. AppKit owns the pointer over the
/// strip and reaches the app through exactly these closures.
@ViewBuilder
private func buildTabStripView(for workspace: ConnectionWorkspace) -> some View {
if let sessionState = workspace.sessionState {
let interaction = workspace.panes.tabStrip.interaction
let _ = configure(interaction, for: workspace, sessionState: sessionState)
private func buildTabStripView(for workspace: ConnectionWorkspace) -> AnyView {
guard let sessionState = workspace.sessionState else { return AnyView(Color.clear) }
let interaction = workspace.panes.tabStrip.interaction
configure(interaction, for: workspace, sessionState: sessionState)
return AnyView(
EditorTabStrip(
tabManager: sessionState.tabManager,
interaction: interaction,
Expand All @@ -99,9 +99,7 @@ internal extension MainSplitViewController {
workspace?.sessionState?.coordinator.commandActions?.newTab()
}
)
} else {
Color.clear
}
)
}

private func configure(
Expand All @@ -127,8 +125,21 @@ internal extension MainSplitViewController {
moveTab: { [weak manager] id, destination in manager?.moveTab(id: id, to: destination) },
canMove: { [weak manager] id, offset in manager?.canMoveTab(id: id, by: offset) ?? false },
moveBy: { [weak manager] id, offset in manager?.moveTab(id: id, by: offset) },
tearOff: { _ in },
canTearOff: { _ in false },
tearOff: { [weak workspace] id in
guard let connectionId = workspace?.connectionId else { return }
WindowManager.shared.openTabInNewWindow(connectionId: connectionId, tabId: id)
},
canTearOff: { [weak workspace] id in
guard let workspace,
let sessionState = workspace.sessionState
else { return false }
return EditorTabDetachPolicy.canDetach(
tabCount: sessionState.tabManager.tabs.count,
hasUnsavedWork: sessionState.coordinator.hasUnsavedWork(forTab: id),
isBusy: sessionState.coordinator.tabExecution.isExecuting(id),
isConnected: DatabaseManager.shared.activeSessions[workspace.connectionId]?.driver != nil
)
},
/// The resolver's description, not the drawn title: a table tab carries its database
/// and schema there even when the short title is unique, and the tooltip is where a
/// truncated or duplicated name is told apart.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ internal final class TabPersistenceCoordinator {
/// instruction deleted the state the disconnect had just saved.
private(set) var hasObservedTabs = false

@ObservationIgnored nonisolated(unsafe) private static var shared: [UUID: TabPersistenceCoordinator] = [:]

/// One per connection, however many windows host it.
///
/// The saved tab set is the connection's, not the window's, so two windows on one connection
/// must not each keep their own view of whether a restore has run: `hasObservedTabs` is the
/// gate that stops a partial list being written over a full one, and a second instance starts
/// with it closed. A detached window's coordinator would then have every save withheld, and
/// whichever instance the periodic save happened to elect decided whether anything reached
/// disk at all.
internal static func forConnection(_ connectionId: UUID) -> TabPersistenceCoordinator {
if let existing = shared[connectionId] { return existing }
let created = TabPersistenceCoordinator(connectionId: connectionId)
shared[connectionId] = created
return created
}

init(connectionId: UUID) {
self.connectionId = connectionId
}
Expand Down Expand Up @@ -110,6 +127,15 @@ internal final class TabPersistenceCoordinator {
/// No automatic save path may call this: an empty in-memory tab list is not consent to
/// discard what is on disk.
internal func clearForUserClosedAllTabs() {
/// The union across every window hosting the connection, not the manager that just
/// emptied. Closing a container can empty a detached window while the original still holds
/// tabs, and discarding the saved set there would take those with it.
guard MainContentCoordinator.aggregatedTabs(for: connectionId).isEmpty else {
Self.logger.debug(
"[persist] clear refused, other windows still hold tabs connId=\(self.connectionId, privacy: .public)"
)
return
}
saveTask?.cancel()
saveTask = nil
let connId = connectionId
Expand Down
99 changes: 95 additions & 4 deletions TablePro/Core/Services/Infrastructure/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,85 @@ internal final class WindowManager {
}
}

/// Moves one tab into a window of its own, the way a window tab is dragged out of its group.
///
/// The connection is then hosted by two windows. That is a state the app already had before
/// 0.65.0 and kept the machinery for: the session, its driver and its saved tab set are the
/// connection's, not the window's, so both windows share one `ConnectionSession` and the close
/// path already refuses to disconnect while `hasOpenWindow(for:)` still answers true. What each
/// window owns is a `QueryTabManager`, and the tab moves between those.
///
/// The new window is given the tab through a pre-built session state rather than through the
/// payload, so nothing re-opens or re-restores it, and the intent is `.openContent` because
/// `.restoreOrDefault` would fill the new window with the whole saved set.
@discardableResult
internal func openTabInNewWindow(connectionId: UUID, tabId: UUID) -> Bool {
/// Resolved by which workspace actually holds the tab. After one detach the connection is
/// hosted twice, and the singular lookup names an arbitrary one of them: asking it for a
/// tab that lives in the other window fails a move the user did ask for.
guard let origin = workspaces(for: connectionId).first(where: { workspace in
workspace.sessionState?.tabManager.tabs.contains { $0.id == tabId } ?? false
}),
let sourceState = origin.sessionState,
let tab = sourceState.tabManager.tabs.first(where: { $0.id == tabId }),
let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection
else { return false }

let payload = EditorTabPayload(
connectionId: connectionId,
tabType: tab.tabType,
tableName: tab.tableContext.tableName,
databaseName: tab.tableContext.databaseName,
schemaName: tab.tableContext.schemaName,
sourceFileURL: tab.content.sourceFileURL,
tabTitle: tab.title,
intent: .openContent
)
let state = SessionStateFactory.create(connection: connection, payload: nil)
state.tabManager.tabs = [tab]
state.tabManager.selectedTabId = tab.id

/// The rows the tab already loaded live in its `TabSession`, which belongs to the
/// coordinator's registry rather than to the tab. Without handing it over the new window
/// shows an empty grid it will not refill: the tab's `lastExecutedAt` is already set, so
/// nothing asks for the page again.
if let liveSession = sourceState.coordinator.tabSessionRegistry.session(for: tabId) {
state.coordinator.tabSessionRegistry.register(liveSession)
}
SessionStateFactory.registerPending(state, for: payload.id)

guard let window = buildWindow(payload: payload, sessionState: state, autoConnect: false) else {
/// The pending entry expires on its own, but leaving it for the timeout would let the
/// next window opened for this connection adopt a session state holding a tab that
/// never left its old window.
SessionStateFactory.removePending(for: payload.id)
return false
}

/// Removed only once the window exists. `closeTab` is the move-out primitive here: it takes
/// the tab out of the array and settles the selection, and unlike the user's own close it
/// neither prompts nor clears anything from disk.
sourceState.coordinator.tabSessionRegistry.unregister(id: tabId)
sourceState.tabManager.closeTab(id: tabId)

/// A file's window mapping follows its tab, or reopening the file focuses the window the
/// tab has left and does nothing there.
if let sourceURL = tab.content.sourceFileURL,
let windowId = (window.contentViewController as? MainSplitViewController)?
.workspaces.workspace(for: connectionId)?.sessionState?.coordinator.windowId {
WindowLifecycleMonitor.shared.registerSourceFile(sourceURL, windowId: windowId)
}

/// Ordered front as a window of its own. Left at `.automatic` the system preference can
/// merge it straight back into the tab group it was asked to leave, which is the trap
/// `openStandaloneWindow` already documents.
window.tabbingMode = .disallowed
window.makeKeyAndOrderFront(nil)
window.tabbingMode = .automatic
AppActivationPolicyController.shared.activate(ignoringOtherApps: true)
return true
}

private func buildWindow(
payload: EditorTabPayload,
sessionState: SessionStateFactory.SessionState?,
Expand Down Expand Up @@ -273,10 +352,22 @@ internal final class WindowManager {
}

internal func workspace(for connectionId: UUID) -> ConnectionWorkspace? {
hosts()
.lazy
.compactMap { $0.workspaces.workspace(for: connectionId) }
.first
workspaces(for: connectionId).first
}

/// Every window hosting this connection, in window order.
///
/// A connection is hosted by more than one window as soon as a tab is torn off into its own
/// (`openTabInNewWindow`). The single-workspace lookup above then names an arbitrary one of
/// them, so anything that has to see all of a connection's tabs, or act on all of them, asks
/// this instead. One window still holds at most one workspace per connection, which is why
/// the per-window registry stays keyed by connection id.
internal func workspaces(for connectionId: UUID) -> [ConnectionWorkspace] {
hosts().compactMap { $0.workspaces.workspace(for: connectionId) }
}

internal func coordinators(for connectionId: UUID) -> [MainContentCoordinator] {
workspaces(for: connectionId).compactMap { $0.sessionState?.coordinator }
}

/// The window hosting this connection, whatever state it is in.
Expand Down
Loading
Loading