Skip to content
Merged
25 changes: 10 additions & 15 deletions apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@

import AppKit

/// "Open Database" and "Close Tabs for Other Databases" read Database or Schema
/// depending on what the driver switches between. That is a closed two-string set
/// driven by `ContainerSwitchTarget`, not an interpolated driver name, because
/// System Settings binds an App Shortcut to a menu item's exact literal title.
// Database-or-Schema titles are a closed set, never interpolated: System Settings binds an App Shortcut to the exact title.
@MainActor
enum DatabaseMenuBuilder {
static func build(keyboard: KeyboardSettings) -> NSMenuItem {
Expand Down Expand Up @@ -64,10 +61,7 @@ enum DatabaseMenuBuilder {
String(localized: "Search Database…"),
action: #selector(MainSplitViewController.searchDatabase(_:))
),
/// The sidebar's own Copy To and Duplicate Database, mirrored so both are reachable
/// from the keyboard. The menu acts on the database being browsed, which is what a
/// command with no clicked row can mean. Spelled exactly as the sidebar and the sheet
/// spell it: one command carrying two names reads as two commands.
// Mirrors the sidebar, acting on the browsed database; one command with two names reads as two.
MenuItemFactory.item(
String(localized: "Copy To…"),
action: #selector(MainSplitViewController.copyObjectsToDatabase(_:))
Expand All @@ -85,8 +79,7 @@ enum DatabaseMenuBuilder {
String(localized: "Edit View Definition…"),
action: #selector(MainSplitViewController.editViewDefinition(_:))
),
/// The sidebar's own per-object commands, mirrored so each is reachable from the menu
/// bar and the keyboard, and spelled exactly as the sidebar spells them.
// The sidebar's per-object commands, spelled as the sidebar spells them.
MenuItemFactory.item(
String(localized: "Show DDL"),
action: #selector(MainSplitViewController.showObjectDDL(_:))
Expand Down Expand Up @@ -133,24 +126,26 @@ enum DatabaseMenuBuilder {
),
MenuItemFactory.item(
String(localized: "Server Dashboard"),
action: #selector(MainSplitViewController.showServerDashboard(_:))
action: #selector(MainSplitViewController.showServerDashboard(_:)),
shortcut: .showServerDashboard,
keyboard: keyboard
),
MenuItemFactory.item(
String(localized: "Users & Roles"),
action: #selector(MainSplitViewController.showUsersAndRoles(_:))
),
MenuItemFactory.item(
String(localized: "Query Insights"),
action: #selector(MainSplitViewController.showQueryInsights(_:))
action: #selector(MainSplitViewController.showQueryInsights(_:)),
shortcut: .showQueryInsights,
keyboard: keyboard
),
MenuItemFactory.item(
String(localized: "Table Health"),
action: #selector(MainSplitViewController.showTableHealth(_:))
),
MenuItemFactory.separator,
/// No ellipsis: the HIG reserves one for an action that needs more information before
/// it can complete, and this needs none. It validates to disabled for every connection
/// whose driver holds no file, which is all of them but the embedded engines.
// No ellipsis: the HIG keeps one for a command that asks for more input, and this asks for none.
MenuItemFactory.item(
String(localized: "Release File Lock"),
action: #selector(MainSplitViewController.releaseFileLock(_:))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,25 @@ internal enum ConnectionCloseAction {
case confirmUnsavedWork
}

/// 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.
// Every window hosting the connection, every tab in each: saving only the selected tab closed over the rest.
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 }
guard await coordinator.commandActions?.saveEveryTabsWork() == true else { return false }
}
return !targets.isEmpty
}

private static func unsavedWorkMessage(across coordinators: [MainContentCoordinator]) -> String {
let keepsTabsOpen = coordinators.contains { coordinator in
coordinator.commandActions?.holdsWorkOnlyItsTabCanSave(among: coordinator.tabManager.tabs) == true
}
return MainContentCommandActions.unsavedWorkMessage(keepsTabsOpen: keepsTabsOpen)
}

/// The transaction is asked about before unsaved work, whose Save writes at once: a Cancel on the
/// transaction after it would keep the connection open with that write already made.
internal static func decision(hasSession: Bool, holdsTransaction: Bool, hasUnsavedWork: Bool) -> Decision {
Expand Down Expand Up @@ -106,8 +109,9 @@ internal enum ConnectionCloseAction {
}

let presentingWindow = reveal(connectionId: connectionId)
let targets = coordinators.isEmpty ? [coordinator].compactMap { $0 } : coordinators
switch await AlertHelper.confirmSaveChanges(
message: String(localized: "Your changes will be lost if you don't save them."),
message: unsavedWorkMessage(across: targets),
window: presentingWindow
) {
case .save:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,14 @@ internal final class ConnectionWorkspace {
/// left the mode with the second window's layout.
internal var browseCollapseState: (sidebar: Bool, inspector: Bool)?

/// Each workspace owns its undo stack. Routing through `NSWindow.undoManager` was correct
/// while a window meant one connection; sharing one window between several would let an
/// undo in one connection roll back an edit made in another.
// Per workspace: one window hosts several connections, and a shared stack undid one connection's edit from another.
internal let undoManager: UndoManager
// Agent mode's own history: the browse one holds edits to a grid the mode hides.
internal let agentUndoManager = UndoManager()

internal var activeUndoManager: UndoManager {
resolvedContentMode == .agent ? agentUndoManager : undoManager
}

/// This connection's own view tree, built once and kept. The window shows one workspace's panes
/// at a time by swapping which of these is the split items' child.
Expand Down Expand Up @@ -401,6 +405,7 @@ internal final class ConnectionWorkspace {
sessionState = nil
session = nil
undoManager.removeAllActions()
agentUndoManager.removeAllActions()
/// Once nothing hosts this connection any more, its agent sessions stop: a stream, a tool
/// loop or a card waiting for an answer would otherwise keep running with nothing on screen.
/// Deferred so this workspace has already left the window's registry when the check runs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,41 @@ extension MainSplitViewController: GridRowCopyCommandResponding {}

// Fallback responders; paste: must be here, or AppKit disables Paste and the disabled item swallows Cmd+V window-wide.
extension MainSplitViewController {
// Agent mode draws no tab or grid, so Undo runs the mode's own history, never the browse one behind it.
var agentUndoManager: UndoManager? {
contentMode == .agent ? workspaces.selected?.agentUndoManager : nil
}

@objc func undo(_ sender: Any?) {
guard contentMode == .browse else {
agentUndoManager?.undo()
return
}
commandActions?.undoChange()
}

@objc func redo(_ sender: Any?) {
guard contentMode == .browse else {
agentUndoManager?.redo()
return
}
commandActions?.redoChange()
}

var undoTitle: String {
guard contentMode == .browse else {
return agentUndoManager?.undoMenuItemTitle ?? String(localized: "Undo")
}
return commandActions?.resolvedUndoTitle ?? String(localized: "Undo")
}

var redoTitle: String {
guard contentMode == .browse else {
return agentUndoManager?.redoMenuItemTitle ?? String(localized: "Redo")
}
return commandActions?.resolvedRedoTitle ?? String(localized: "Redo")
}

@objc func copy(_ sender: Any?) {
guard let actions = commandActions else { return }
if actions.hasRowSelection {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,9 @@ extension MainSplitViewController {
commandActions?.newTab()
}

/// The editor's meaning of Close, reached through `EditorWindow.performClose(_:)`.
///
/// It is not an action itself, because the window already is one: `performClose:` resolves to
/// the nearest responder that claims it, so the connections strip still takes Close while it
/// holds the keyboard, and everything else in the window falls through to the window.
///
/// A connecting or failed pane has no command surface, so Close ends the connection instead.
/// Leaving that to `commandActions` made the command inert on exactly the pane a user most
/// wants to dismiss.
/// Returns false when the window hosts nothing to close, so the window falls back to closing
/// itself rather than letting the command resolve to nothing, which is the failure this whole
/// route exists to make impossible.
/// What Close does here, in the words the connections strip already uses for the same command.
/// A window with no tab left closes the connection, so naming it "Close Tab" would describe an
/// action the command does not take.
// The strip's words for the same command. Agent mode shows no tab, so it names what a window with none would close.
var closeCommandTitle: String? {
if commandActions?.hasOpenTab == true { return String(localized: "Close Tab") }
if contentMode == .browse, commandActions?.hasOpenTab == true { return String(localized: "Close Tab") }
if let workspace = workspaces.selected,
let entry = WorkspaceCloseAction.closableEntry(
connectionId: workspace.connectionId,
Expand All @@ -53,41 +39,30 @@ extension MainSplitViewController {
return String(format: String(localized: "Close “%@”"), name)
}

// Reached through `EditorWindow.performClose(_:)`; false lets the window close itself when it hosts nothing.
func closeFrontmostTab() -> Bool {
guard let actions = commandActions else {
guard let connectionId = workspaces.selectedConnectionId else { return false }
WindowManager.shared.closeWindow(for: connectionId)
return true
}
/// A window that exists only to hold a tab moved out of another one closes with that tab.
/// `closeTab` deliberately leaves a window standing when its last tab goes, because the
/// connection is still open in it and its object browser is still useful; that is the right
/// answer for the window a connection lives in and the wrong one here, where Close would
/// empty the window and pressing Close again would take the connection down in both.
///
/// It still goes through `closeTabAwaiting`, which is the primitive Cmd+W is required to
/// keep. Skipping to `super.performClose` closed the window over a save prompt that was
/// never shown and never reached Recently Closed Tabs. The window closes only once the tab
/// actually went, so Cancel at the prompt leaves both standing.
guard contentMode == .browse else {
actions.closeWorkspace()
return true
}
// A window holding only a moved tab closes with it, after `closeTabAwaiting` so a cancelled save prompt keeps both.
if isDetachedSingleTabWindow, let selected = workspaces.selected?.sessionState?.tabManager.selectedTab {
Task { @MainActor [weak self] in
await actions.closeTabAwaiting(id: selected.id)
guard let self,
self.workspaces.selected?.sessionState?.tabManager.tabs.isEmpty == true,
let workspace = self.workspaces.selected
else { return }
/// Asked again after the await. The save sheet can stand for as long as the user
/// likes, and the window this tab was moved out of can close underneath it: closing
/// then takes the connection's last window with it, which disconnects the session
/// and skips the whole-window confirmation that close would otherwise raise.
// Asked again: the source window can close during the prompt, and this one would then be the connection's last.
guard WindowManager.shared.workspaces(for: workspace.connectionId).count > 1 else {
return
}
/// `closeWindowAwaiting`, not a raw `close()`. An inspector edit is connection
/// scoped, so `closeTabAwaiting` deliberately does not ask about it; closing the
/// window directly then tore down the per-window `RightPanelState` and dropped it
/// with no prompt. With the tab already gone this window has none left, which is
/// the branch that closes it.
// Not a raw `close()`, which dropped an unsaved inspector edit with no prompt.
await actions.closeWindowAwaiting()
}
return true
Expand Down
Loading
Loading